diff --git a/api/Makefile b/api/Makefile index 546b67dea5..f4331d9b42 100644 --- a/api/Makefile +++ b/api/Makefile @@ -57,6 +57,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/logs.yaml >> $(V4_YAML) @cat $(V4_SRC)/outgoing_oauth_connections.yaml >> $(V4_YAML) @cat $(V4_SRC)/metrics.yaml >> $(V4_YAML) + @cat $(V4_SRC)/scheduled_post.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index b591ba0b5d..2171832e98 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3779,6 +3779,46 @@ components: audiences: description: The audiences of the outgoing OAuth connection. type: string + ScheduledPost: + type: object + properties: + id: + type: string + create_at: + description: The time in milliseconds a scheduled post was created + type: integer + format: int64 + update_at: + description: The time in milliseconds a scheduled post was last updated + type: integer + format: int64 + user_id: + type: string + channel_id: + type: string + root_id: + type: string + message: + type: string + props: + type: object + file_ids: + type: array + items: + type: string + scheduled_at: + description: The time in milliseconds a scheduled post is scheduled to be sent at + type: integer + format: int64 + processed_at: + description: The time in milliseconds a scheduled post was processed at + type: integer + format: int64 + error_code: + type: string + description: Explains the error behind why a scheduled post could not have been sent + metadata: + $ref: "#/components/schemas/PostMetadata" externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/scheduled_post.yaml b/api/v4/source/scheduled_post.yaml new file mode 100644 index 0000000000..14f2b4cd3c --- /dev/null +++ b/api/v4/source/scheduled_post.yaml @@ -0,0 +1,203 @@ + /api/v4/posts/schedule: + post: + tags: + - scheduled_post + summary: Creates a scheduled post + description: > + Creates a scheduled post + + ##### Permissions + + Must have `create_post` permission for the channel the post is being created in. + + __Minimum server version__: 10.3 + operationId: CreateScheduledPost + requestBody: + content: + application/json: + schema: + type: object + required: + - channel_id + - message + - scheduled_at + properties: + scheduled_at: + type: integer + description: UNIX timestamp in milliseconds of the time when the scheduled post should be sent + channel_id: + type: string + description: The channel ID to post in + message: + type: string + description: The message contents, can be formatted with Markdown + root_id: + type: string + description: The post ID to comment on + file_ids: + type: array + description: A list of file IDs to associate with the post. Note that + posts are limited to 5 files maximum. Please use additional + posts for more files. + props: + description: A general JSON property bag to attach to the post + type: object + responses: + "200": + description: Created scheduled post + content: + application/json: + schema: + type: object + items: + $ref: "#/components/schemas/ScheduledPost" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + + /api/v4/posts/scheduled/team/{team_id}: + get: + tags: + - scheduled_post + summary: Gets all scheduled posts for a user for the specified team.. + description: > + Get user-team scheduled posts + + ##### Permissions + + Must have `view_team` permission for the team the scheduled posts are being fetched for. + + __Minimum server version__: 10.3 + operationId: GetUserScheduledPosts + parameters: + - name: includeDirectChannels + in: query + description: Whether to include scheduled posts from DMs an GMs or not. Default is false + required: false + schema: + type: boolean + default: false + responses: + "200": + description: Created scheduled post + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: "#/components/schemas/ScheduledPost" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + + /api/v4/posts/schedule/{scheduled_post_id}: + put: + tags: + - scheduled_post + summary: Update a scheduled post + description: > + Updates a scheduled post + + ##### Permissions + + Must have `create_post` permission for the channel where the scheduled post belongs to. + + __Minimum server version__: 10.3 + operationId: UpdateScheduledPost + parameters: + - name: scheduled_post_id + in: path + description: ID of the scheduled post to update + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + required: + - id + - channel_id + - user_id + - message + - scheduled_at + properties: + id: + description: ID of the scheduled post to update + type: string + channel_id: + type: string + description: The channel ID to post in + user_id: + type: string + description: The current user ID + scheduled_at: + type: integer + description: UNIX timestamp in milliseconds of the time when the scheduled post should be sent + message: + type: string + description: The message contents, can be formatted with Markdown + responses: + "200": + description: Updated scheduled post + content: + application/json: + schema: + type: object + items: + $ref: "#/components/schemas/ScheduledPost" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + + delete: + tags: + - scheduled_post + summary: Delete a scheduled post + description: > + Delete a scheduled post + + __Minimum server version__: 10.3 + operationId: DeleteScheduledPost + parameters: + - name: scheduled_post_id + in: path + description: ID of the scheduled post to delete + required: true + schema: + type: string + responses: + "200": + description: Deleted scheduled post + content: + application/json: + schema: + type: object + items: + $ref: "#/components/schemas/ScheduledPost" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 2fb9a7b5b4..012bb01dd7 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -337,6 +337,7 @@ func Init(srv *app.Server) (*API, error) { api.InitLimits() api.InitOutgoingOAuthConnection() api.InitClientPerformanceMetrics() + api.InitScheduledPost() // If we allow testing then listen for manual testing URL hits if *srv.Config().ServiceSettings.EnableTesting { diff --git a/server/channels/api4/handlers.go b/server/channels/api4/handlers.go index b057145866..e923f96af1 100644 --- a/server/channels/api4/handlers.go +++ b/server/channels/api4/handlers.go @@ -239,12 +239,7 @@ func requireLicense(c *Context) *model.AppError { } func minimumProfessionalLicense(c *Context) *model.AppError { - lic := c.App.Srv().License() - if lic == nil || (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) { - err := model.NewAppError("", model.NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) - return err - } - return nil + return model.MinimumProfessionalProvidedLicense(c.App.Srv().License()) } func setHandlerOpts(handler *web.Handler, opts ...APIHandlerOption) { diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index e33f1d904e..8280877f5a 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -48,6 +48,26 @@ func (api *API) InitPost() { api.BaseRoutes.Post.Handle("/move", api.APISessionRequired(moveThread)).Methods(http.MethodPost) } +func createPostChecks(where string, c *Context, post *model.Post) { + // *************************************************************** + // NOTE - if you make any change here, please make sure to apply the + // same change for scheduled posts as well in the `scheduledPostChecks()` function + // in API layer. + // *************************************************************** + + userCreatePostPermissionCheckWithContext(c, post.ChannelId) + if c.Err != nil { + return + } + + postHardenedModeCheckWithContext(where, c, post.GetProps()) + if c.Err != nil { + return + } + + postPriorityCheckWithContext(where, c, post.GetPriority(), post.RootId) +} + func createPost(c *Context, w http.ResponseWriter, r *http.Request) { var post model.Post if jsonErr := json.NewDecoder(r.Body).Decode(&post); jsonErr != nil { @@ -56,87 +76,19 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { } post.SanitizeInput() - post.UserId = c.AppContext.Session().UserId auditRec := c.MakeAuditRecord("createPost", audit.Fail) defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) audit.AddEventParameterAuditable(auditRec, "post", &post) - hasPermission := false - if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionCreatePost) { - hasPermission = true - } else if channel, err := c.App.GetChannel(c.AppContext, post.ChannelId); err == nil { - // Temporary permission check method until advanced permissions, please do not copy - if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) { - hasPermission = true - } - } - - if !hasPermission { - c.SetPermissionError(model.PermissionCreatePost) - return - } - if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode { - if reservedProps := post.ContainsIntegrationsReservedProps(); len(reservedProps) > 0 && !c.AppContext.Session().IsIntegration() { - c.SetInvalidParamWithDetails("props", fmt.Sprintf("Cannot use props reserved for integrations. props: %v", reservedProps)) - return - } - } - if post.CreateAt != 0 && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { post.CreateAt = 0 } - if post.GetPriority() != nil { - priorityForbiddenErr := model.NewAppError("Api4.createPost", "api.post.post_priority.priority_post_not_allowed_for_user.request_error", nil, "userId="+c.AppContext.Session().UserId, http.StatusForbidden) - - if !c.App.IsPostPriorityEnabled() { - c.Err = priorityForbiddenErr - return - } - - if post.RootId != "" { - c.Err = model.NewAppError("Api4.createPost", "api.post.post_priority.priority_post_only_allowed_for_root_post.request_error", nil, "", http.StatusBadRequest) - return - } - - if ack := post.GetRequestedAck(); ack != nil && *ack { - licenseErr := minimumProfessionalLicense(c) - if licenseErr != nil { - c.Err = licenseErr - return - } - } - - if notification := post.GetPersistentNotification(); notification != nil && *notification { - licenseErr := minimumProfessionalLicense(c) - if licenseErr != nil { - c.Err = licenseErr - return - } - if !c.App.IsPersistentNotificationsEnabled() { - c.Err = priorityForbiddenErr - return - } - - if !post.IsUrgent() { - c.Err = model.NewAppError("Api4.createPost", "api.post.post_priority.urgent_persistent_notification_post.request_error", nil, "", http.StatusBadRequest) - return - } - - if !*c.App.Config().ServiceSettings.AllowPersistentNotificationsForGuests { - user, err := c.App.GetUser(c.AppContext.Session().UserId) - if err != nil { - c.Err = err - return - } - if user.IsGuest() { - c.Err = priorityForbiddenErr - return - } - } - } + createPostChecks("Api4.createPost", c, &post) + if c.Err != nil { + return } setOnline := r.URL.Query().Get("set_online") @@ -888,11 +840,9 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode { - if reservedProps := post.ContainsIntegrationsReservedProps(); len(reservedProps) > 0 && !c.AppContext.Session().IsIntegration() { - c.SetInvalidParamWithDetails("props", fmt.Sprintf("Cannot use props reserved for integrations. props: %v", reservedProps)) - return - } + postHardenedModeCheckWithContext("UpdatePost", c, post.GetProps()) + if c.Err != nil { + return } originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false) @@ -957,9 +907,9 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { audit.AddEventParameterAuditable(auditRec, "patch", &post) defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) - if *c.App.Config().ServiceSettings.ExperimentalEnableHardenedMode { - if reservedProps := post.ContainsIntegrationsReservedProps(); len(reservedProps) > 0 && !c.AppContext.Session().IsIntegration() { - c.SetInvalidParamWithDetails("props", fmt.Sprintf("Cannot use props reserved for integrations. props: %v", reservedProps)) + if post.Props != nil { + postHardenedModeCheckWithContext("patchPost", c, *post.Props) + if c.Err != nil { return } } diff --git a/server/channels/api4/post_utils.go b/server/channels/api4/post_utils.go new file mode 100644 index 0000000000..d135a0fbfd --- /dev/null +++ b/server/channels/api4/post_utils.go @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/app" +) + +func userCreatePostPermissionCheckWithContext(c *Context, channelId string) { + hasPermission := false + if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionCreatePost) { + hasPermission = true + } else if channel, err := c.App.GetChannel(c.AppContext, channelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) { + hasPermission = true + } + } + + if !hasPermission { + c.SetPermissionError(model.PermissionCreatePost) + return + } +} + +func postHardenedModeCheckWithContext(where string, c *Context, props model.StringInterface) { + isIntegration := c.AppContext.Session().IsIntegration() + + if appErr := app.PostHardenedModeCheckWithApp(c.App, isIntegration, props); appErr != nil { + appErr.Where = where + c.Err = appErr + } +} + +func postPriorityCheckWithContext(where string, c *Context, priority *model.PostPriority, rootId string) { + appErr := app.PostPriorityCheckWithApp(where, c.App, c.AppContext.Session().UserId, priority, rootId) + if appErr != nil { + appErr.Where = where + c.Err = appErr + } +} diff --git a/server/channels/api4/scheduled_post.go b/server/channels/api4/scheduled_post.go new file mode 100644 index 0000000000..254ec1968e --- /dev/null +++ b/server/channels/api4/scheduled_post.go @@ -0,0 +1,225 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/channels/audit" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +func (api *API) InitScheduledPost() { + api.BaseRoutes.Posts.Handle("/schedule", api.APISessionRequired(createSchedulePost)).Methods(http.MethodPost) + api.BaseRoutes.Posts.Handle("/schedule/{scheduled_post_id:[A-Za-z0-9]+}", api.APISessionRequired(updateScheduledPost)).Methods(http.MethodPut) + api.BaseRoutes.Posts.Handle("/schedule/{scheduled_post_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteScheduledPost)).Methods(http.MethodDelete) + api.BaseRoutes.Posts.Handle("/scheduled/team/{team_id:[A-Za-z0-9]+}", api.APISessionRequired(getTeamScheduledPosts)).Methods(http.MethodGet) +} + +func scheduledPostChecks(where string, c *Context, scheduledPost *model.ScheduledPost) { + // *************************************************************** + // NOTE - if you make any change here, please make sure to apply the + // same change for scheduled posts job as well in the `canPostScheduledPost()` function + // in app layer. + // *************************************************************** + + userCreatePostPermissionCheckWithContext(c, scheduledPost.ChannelId) + if c.Err != nil { + return + } + + postHardenedModeCheckWithContext(where, c, scheduledPost.GetProps()) + if c.Err != nil { + return + } + + postPriorityCheckWithContext(where, c, scheduledPost.GetPriority(), scheduledPost.RootId) +} + +func requireScheduledPostsEnabled(c *Context) { + if !*c.App.Srv().Config().ServiceSettings.ScheduledPosts { + c.Err = model.NewAppError("", "api.scheduled_posts.feature_disabled", nil, "", http.StatusBadRequest) + return + } + + if c.App.Channels().License() == nil { + c.Err = model.NewAppError("", "api.scheduled_posts.license_error", nil, "", http.StatusBadRequest) + return + } +} + +func createSchedulePost(c *Context, w http.ResponseWriter, r *http.Request) { + requireScheduledPostsEnabled(c) + if c.Err != nil { + return + } + + var scheduledPost model.ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&scheduledPost); err != nil { + c.SetInvalidParamWithErr("schedule_post", err) + return + } + scheduledPost.UserId = c.AppContext.Session().UserId + scheduledPost.SanitizeInput() + + auditRec := c.MakeAuditRecord("createSchedulePost", audit.Fail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + audit.AddEventParameterAuditable(auditRec, "scheduledPost", &scheduledPost) + + scheduledPostChecks("Api4.createSchedulePost", c, &scheduledPost) + if c.Err != nil { + return + } + + createdScheduledPost, appErr := c.App.SaveScheduledPost(c.AppContext, &scheduledPost) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(createdScheduledPost) + auditRec.AddEventObjectType("scheduledPost") + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(createdScheduledPost); err != nil { + mlog.Error("failed to encode scheduled post to return API response", mlog.Err(err)) + return + } +} + +func getTeamScheduledPosts(c *Context, w http.ResponseWriter, r *http.Request) { + requireScheduledPostsEnabled(c) + if c.Err != nil { + return + } + + c.RequireTeamId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + teamId := c.Params.TeamId + userId := c.AppContext.Session().UserId + + scheduledPosts, appErr := c.App.GetUserTeamScheduledPosts(c.AppContext, userId, teamId) + if appErr != nil { + c.Err = appErr + return + } + + response := map[string][]*model.ScheduledPost{} + response[teamId] = scheduledPosts + + if r.URL.Query().Get("includeDirectChannels") == "true" { + directChannelScheduledPosts, appErr := c.App.GetUserTeamScheduledPosts(c.AppContext, userId, "") + if appErr != nil { + c.Err = appErr + return + } + + response["directChannels"] = directChannelScheduledPosts + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + mlog.Error("failed to encode scheduled posts to return API response", mlog.Err(err)) + return + } +} + +func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { + requireScheduledPostsEnabled(c) + if c.Err != nil { + return + } + + scheduledPostId := mux.Vars(r)["scheduled_post_id"] + if scheduledPostId == "" { + c.SetInvalidURLParam("scheduled_post_id") + return + } + + var scheduledPost model.ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&scheduledPost); err != nil { + c.SetInvalidParamWithErr("schedule_post", err) + return + } + + if scheduledPost.Id != scheduledPostId { + c.SetInvalidURLParam("scheduled_post_id") + return + } + + auditRec := c.MakeAuditRecord("updateScheduledPost", audit.Fail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + audit.AddEventParameterAuditable(auditRec, "scheduledPost", &scheduledPost) + + scheduledPostChecks("Api4.updateScheduledPost", c, &scheduledPost) + if c.Err != nil { + return + } + + userId := c.AppContext.Session().UserId + updatedScheduledPost, appErr := c.App.UpdateScheduledPost(c.AppContext, userId, &scheduledPost) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(updatedScheduledPost) + auditRec.AddEventObjectType("scheduledPost") + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(updatedScheduledPost); err != nil { + mlog.Error("failed to encode scheduled post to return API response", mlog.Err(err)) + return + } +} + +func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { + requireScheduledPostsEnabled(c) + if c.Err != nil { + return + } + + scheduledPostId := mux.Vars(r)["scheduled_post_id"] + if scheduledPostId == "" { + c.SetInvalidURLParam("scheduled_post_id") + return + } + + auditRec := c.MakeAuditRecord("deleteScheduledPost", audit.Fail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + audit.AddEventParameter(auditRec, "scheduledPostId", scheduledPostId) + + userId := c.AppContext.Session().UserId + connectionID := r.Header.Get(model.ConnectionId) + deletedScheduledPost, appErr := c.App.DeleteScheduledPost(c.AppContext, userId, scheduledPostId, connectionID) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(deletedScheduledPost) + auditRec.AddEventObjectType("scheduledPost") + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(deletedScheduledPost); err != nil { + mlog.Error("failed to encode scheduled post to return API response", mlog.Err(err)) + return + } +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index ae25f54191..91148ca0da 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -584,6 +584,7 @@ type AppIface interface { DeleteReactionForPost(c request.CTX, reaction *model.Reaction) *model.AppError DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError) DeleteRetentionPolicy(policyID string) *model.AppError + DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, connectionId string) (*model.ScheduledPost, *model.AppError) DeleteScheme(schemeId string) (*model.Scheme, *model.AppError) DeleteSharedChannelRemote(id string) (bool, error) DeleteSidebarCategory(c request.CTX, userID, teamID, categoryId string) *model.AppError @@ -870,6 +871,7 @@ type AppIface interface { GetUserByUsername(username string) (*model.User, *model.AppError) GetUserCountForReport(filter *model.UserReportOptions) (*int64, *model.AppError) GetUserForLogin(c request.CTX, id, loginId string) (*model.User, *model.AppError) + GetUserTeamScheduledPosts(rctx request.CTX, userId, teamId string) ([]*model.ScheduledPost, *model.AppError) GetUserTermsOfService(userID string) (*model.UserTermsOfService, *model.AppError) GetUsers(userIDs []string) ([]*model.User, *model.AppError) GetUsersByGroupChannelIds(c request.CTX, channelIDs []string, asAdmin bool) (map[string][]*model.User, *model.AppError) @@ -1002,6 +1004,7 @@ type AppIface interface { PreparePostForClient(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post PreparePostForClientWithEmbedsAndImages(c request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post PreparePostListForClient(c request.CTX, originalList *model.PostList) *model.PostList + ProcessScheduledPosts(rctx request.CTX) ProcessSlackText(text string) string Publish(message *model.WebSocketEvent) PublishUserTyping(userID, channelID, parentId string) *model.AppError @@ -1067,6 +1070,7 @@ type AppIface interface { SaveComplianceReport(rctx request.CTX, job *model.Compliance) (*model.Compliance, *model.AppError) SaveReactionForPost(c request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) SaveReportChunk(format string, prefix string, count int, reportData []model.ReportableObject) *model.AppError + SaveScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) SaveUserTermsOfService(userID, termsOfServiceId string, accepted bool) *model.AppError SchemesIterator(scope string, batchSize int) func() []*model.Scheme @@ -1203,6 +1207,7 @@ type AppIface interface { UpdateRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) UpdateRemoteClusterTopics(remoteClusterId string, topics string) (*model.RemoteCluster, *model.AppError) UpdateRole(role *model.Role) (*model.Role, *model.AppError) + UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) UpdateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) UpdateSharedChannelRemoteCursor(id string, cursor model.GetPostsSinceForSyncCursor) error diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index a63bb5c1e7..fc6dd687f7 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -77,7 +77,9 @@ type Channels struct { postReminderMut sync.Mutex postReminderTask *model.ScheduledTask - loginAttemptsMut sync.Mutex + scheduledPostMut sync.Mutex + scheduledPostTask *model.ScheduledTask + loginAttemptsMut sync.Mutex } func NewChannels(s *Server) (*Channels, error) { diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 0a3836819b..b4e0a7aded 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -3727,6 +3727,28 @@ func (a *OpenTracingAppLayer) DeleteRetentionPolicy(policyID string) *model.AppE return resultVar0 } +func (a *OpenTracingAppLayer) DeleteScheduledPost(rctx request.CTX, userId string, scheduledPostId string, connectionId string) (*model.ScheduledPost, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteScheduledPost") + + 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.DeleteScheduledPost(rctx, userId, scheduledPostId, connectionId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteScheme(schemeId string) (*model.Scheme, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteScheme") @@ -10990,6 +11012,28 @@ func (a *OpenTracingAppLayer) GetUserStatusesByIds(userIDs []string) ([]*model.S return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetUserTeamScheduledPosts(rctx request.CTX, userId string, teamId string) ([]*model.ScheduledPost, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserTeamScheduledPosts") + + 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.GetUserTeamScheduledPosts(rctx, userId, teamId) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetUserTermsOfService(userID string) (*model.UserTermsOfService, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserTermsOfService") @@ -13964,6 +14008,21 @@ func (a *OpenTracingAppLayer) PreparePostListForClient(c request.CTX, originalLi return resultVar0 } +func (a *OpenTracingAppLayer) ProcessScheduledPosts(rctx request.CTX) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ProcessScheduledPosts") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + a.app.ProcessScheduledPosts(rctx) +} + func (a *OpenTracingAppLayer) ProcessSlackAttachments(attachments []*model.SlackAttachment) []*model.SlackAttachment { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ProcessSlackAttachments") @@ -15511,6 +15570,28 @@ func (a *OpenTracingAppLayer) SaveReportChunk(format string, prefix string, coun return resultVar0 } +func (a *OpenTracingAppLayer) SaveScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveScheduledPost") + + 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.SaveScheduledPost(rctx, scheduledPost) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveSharedChannelRemote") @@ -18690,6 +18771,28 @@ func (a *OpenTracingAppLayer) UpdateRole(role *model.Role) (*model.Role, *model. return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateScheduledPost") + + 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.UpdateScheduledPost(rctx, userId, scheduledPost) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) UpdateScheme(scheme *model.Scheme) (*model.Scheme, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UpdateScheme") diff --git a/server/channels/app/post_permission_utils.go b/server/channels/app/post_permission_utils.go new file mode 100644 index 0000000000..d48c278add --- /dev/null +++ b/server/channels/app/post_permission_utils.go @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +func PostPriorityCheckWithApp(where string, a AppIface, userId string, priority *model.PostPriority, rootId string) *model.AppError { + user, appErr := a.GetUser(userId) + if appErr != nil { + return appErr + } + + isPostPriorityEnabled := a.IsPostPriorityEnabled() + IsPersistentNotificationsEnabled := a.IsPersistentNotificationsEnabled() + allowPersistentNotificationsForGuests := *a.Config().ServiceSettings.AllowPersistentNotificationsForGuests + license := a.License() + + appErr = postPriorityCheck(user, priority, rootId, isPostPriorityEnabled, IsPersistentNotificationsEnabled, allowPersistentNotificationsForGuests, license) + if appErr != nil { + appErr.Where = where + return appErr + } + + return nil +} + +func postPriorityCheck( + user *model.User, + priority *model.PostPriority, + rootId string, + isPostPriorityEnabled, + isPersistentNotificationsEnabled, + allowPersistentNotificationsForGuests bool, + license *model.License, +) *model.AppError { + if priority == nil { + return nil + } + + priorityForbiddenErr := model.NewAppError("", "api.post.post_priority.priority_post_not_allowed_for_user.request_error", nil, "userId="+user.Id, http.StatusForbidden) + + if !isPostPriorityEnabled { + return priorityForbiddenErr + } + + if rootId != "" { + return model.NewAppError("", "api.post.post_priority.priority_post_only_allowed_for_root_post.request_error", nil, "", http.StatusBadRequest) + } + + if ack := priority.RequestedAck; ack != nil && *ack { + licenseErr := model.MinimumProfessionalProvidedLicense(license) + if licenseErr != nil { + return licenseErr + } + } + + if notification := priority.PersistentNotifications; notification != nil && *notification { + licenseErr := model.MinimumProfessionalProvidedLicense(license) + if licenseErr != nil { + return licenseErr + } + if !isPersistentNotificationsEnabled { + return priorityForbiddenErr + } + + if *priority.Priority != model.PostPriorityUrgent { + return model.NewAppError("", "api.post.post_priority.urgent_persistent_notification_post.request_error", nil, "", http.StatusBadRequest) + } + + if !allowPersistentNotificationsForGuests { + if user.IsGuest() { + return priorityForbiddenErr + } + } + } + + return nil +} + +func PostHardenedModeCheckWithApp(a AppIface, isIntegration bool, props model.StringInterface) *model.AppError { + hardenedModeEnabled := *a.Config().ServiceSettings.ExperimentalEnableHardenedMode + return postHardenedModeCheck(hardenedModeEnabled, isIntegration, props) +} + +func postHardenedModeCheck(hardenedModeEnabled, isIntegration bool, props model.StringInterface) *model.AppError { + if hardenedModeEnabled { + if reservedProps := model.ContainsIntegrationsReservedProps(props); len(reservedProps) > 0 && !isIntegration { + return model.NewAppError("", "api.context.invalid_body_param.app_error", map[string]any{"Name": "props"}, fmt.Sprintf("Cannot use props reserved for integrations. props: %v", reservedProps), http.StatusBadRequest) + } + } + + return nil +} + +func userCreatePostPermissionCheckWithApp(c request.CTX, a AppIface, userId, channelId string) *model.AppError { + hasPermission := false + if a.HasPermissionToChannel(c, userId, channelId, model.PermissionCreatePost) { + hasPermission = true + } else if channel, err := a.GetChannel(c, channelId); err == nil { + // Temporary permission check method until advanced permissions, please do not copy + if channel.Type == model.ChannelTypeOpen && a.HasPermissionToTeam(c, userId, channel.TeamId, model.PermissionCreatePostPublic) { + hasPermission = true + } + } + + if !hasPermission { + return model.MakePermissionErrorForUser(userId, []*model.Permission{model.PermissionCreatePost}) + } + + return nil +} diff --git a/server/channels/app/scheduled_post.go b/server/channels/app/scheduled_post.go new file mode 100644 index 0000000000..0a8efa471c --- /dev/null +++ b/server/channels/app/scheduled_post.go @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +func (a *App) SaveScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) { + maxMessageLength := a.Srv().Store().ScheduledPost().GetMaxMessageSize() + scheduledPost.PreSave() + if validationErr := scheduledPost.IsValid(maxMessageLength); validationErr != nil { + return nil, validationErr + } + + // validate the channel is not archived + channel, appErr := a.GetChannel(rctx, scheduledPost.ChannelId) + if appErr != nil { + return nil, appErr + } + + if channel.DeleteAt > 0 { + return nil, model.NewAppError("App.scheduledPostPreSaveChecks", "app.save_scheduled_post.channel_deleted.app_error", map[string]any{"user_id": scheduledPost.UserId, "channel_id": scheduledPost.ChannelId}, "", http.StatusBadRequest) + } + + savedScheduledPost, err := a.Srv().Store().ScheduledPost().CreateScheduledPost(scheduledPost) + if err != nil { + return nil, model.NewAppError("App.ScheduledPost", "app.save_scheduled_post.save.app_error", map[string]any{"user_id": scheduledPost.UserId, "channel_id": scheduledPost.ChannelId}, "", http.StatusBadRequest) + } + + // TODO: add WebSocket event broadcast here + + return savedScheduledPost, nil +} + +func (a *App) GetUserTeamScheduledPosts(rctx request.CTX, userId, teamId string) ([]*model.ScheduledPost, *model.AppError) { + scheduledPosts, err := a.Srv().Store().ScheduledPost().GetScheduledPostsForUser(userId, teamId) + if err != nil { + return nil, model.NewAppError("App.GetUserTeamScheduledPosts", "app.get_user_team_scheduled_posts.error", map[string]any{"user_id": userId, "team_id": teamId}, "", http.StatusInternalServerError) + } + + if scheduledPosts == nil { + scheduledPosts = []*model.ScheduledPost{} + } + + for _, scheduledPost := range scheduledPosts { + a.prepareDraftWithFileInfos(rctx, userId, &scheduledPost.Draft) + } + + return scheduledPosts, nil +} + +func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, *model.AppError) { + maxMessageLength := a.Srv().Store().ScheduledPost().GetMaxMessageSize() + scheduledPost.PreUpdate() + if validationErr := scheduledPost.IsValid(maxMessageLength); validationErr != nil { + return nil, validationErr + } + + // validate the scheduled post belongs to the said user + existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPost.Id) + if err != nil { + return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError) + } + + if existingScheduledPost == nil { + return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusNotFound) + } + + if existingScheduledPost.UserId != userId { + return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusForbidden) + } + + // This step is not required for update but is useful as we want to return the + // updated scheduled post. It's better to do this before calling update than after. + scheduledPost.RestoreNonUpdatableFields(existingScheduledPost) + + if err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(scheduledPost); err != nil { + return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError) + } + + // TODO: add WebSocket event broadcast here. This will be done in a later PR + + return scheduledPost, nil +} + +func (a *App) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, connectionId string) (*model.ScheduledPost, *model.AppError) { + scheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPostId) + if err != nil { + return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError) + } + + if scheduledPost == nil { + return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusNotFound) + } + + if scheduledPost.UserId != userId { + return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusForbidden) + } + + if err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPostId}); err != nil { + return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError) + } + + // TODO: add WebSocket event broadcast here. This will be done in a later PR + + return scheduledPost, nil +} diff --git a/server/channels/app/scheduled_post_job.go b/server/channels/app/scheduled_post_job.go new file mode 100644 index 0000000000..6f03a50824 --- /dev/null +++ b/server/channels/app/scheduled_post_job.go @@ -0,0 +1,357 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "net/http" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/pkg/errors" +) + +const ( + getPendingScheduledPostsPageSize = 100 + scheduledPostBatchWaitTime = 1 * time.Second +) + +func (a *App) ProcessScheduledPosts(rctx request.CTX) { + rctx = rctx.WithLogger(rctx.Logger().With(mlog.String("component", "scheduled_post_job"))) + rctx.Logger().Debug("ProcessScheduledPosts called...") + + if !*a.Config().ServiceSettings.ScheduledPosts { + rctx.Logger().Debug("ProcessScheduledPosts exiting as the feature is turned off via ServiceSettings.ScheduledPosts setting...") + return + } + + if a.License() == nil { + rctx.Logger().Debug("ProcessScheduledPosts exiting as no license is available") + return + } + + beforeTime := model.GetMillis() + afterTime := beforeTime - (24 * 60 * 60 * 1000) // subtracting 24 hours from beforeTime + lastScheduledPostId := "" + + for { + // we wait some time before processing each batch to avoid hammering the database with too many requests. + time.Sleep(scheduledPostBatchWaitTime) + rctx.Logger().Debug("ProcessScheduledPosts: fetching page of pending scheduled posts...") + + scheduledPostsBatch, err := a.Srv().Store().ScheduledPost().GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, getPendingScheduledPostsPageSize) + if err != nil { + rctx.Logger().Error( + "App.ProcessScheduledPosts: failed to fetch pending scheduled posts page from database", + mlog.Int("before_time", beforeTime), + mlog.String("last_scheduled_post_id", lastScheduledPostId), + mlog.Int("items_per_page", getPendingScheduledPostsPageSize), + mlog.Err(err), + ) + + // Break the loop if we can't fetch the page. + // Missed posts will be processed in job's next round. + // Since we don't know any item's details, we can't fetch the next page as well. + // We could retry here but that's the same as trying in job's next round. + break + } + + rctx.Logger().Debug("ProcessScheduledPosts: entries found in page of pending scheduled posts", mlog.Int("entries", len(scheduledPostsBatch))) + if len(scheduledPostsBatch) == 0 { + rctx.Logger().Debug("ProcessScheduledPosts: skipping as there are no pending scheduled") + // break loop if there are no more scheduled posts + break + } + + // Saving the last item to use as marker for next page + lastScheduledPostId = scheduledPostsBatch[len(scheduledPostsBatch)-1].Id + beforeTime = scheduledPostsBatch[len(scheduledPostsBatch)-1].ScheduledAt + + if err := a.processScheduledPostBatch(rctx, scheduledPostsBatch); err != nil { + rctx.Logger().Error( + "App.ProcessScheduledPosts: failed to process scheduled posts batch", + mlog.Int("before_time", beforeTime), + mlog.String("last_scheduled_post_id", lastScheduledPostId), + mlog.Int("items_per_page", getPendingScheduledPostsPageSize), + mlog.Err(err), + ) + + // failure to process one batch doesn't mean other batches will fail as well. + // Continue processing next batch. The posts that failed in this batch will be picked + // up when the job next runs. + continue + } + + rctx.Logger().Debug("ProcessScheduledPosts: finished processing a page of pending scheduled posts.") + + if len(scheduledPostsBatch) < getPendingScheduledPostsPageSize { + // if we got less than page size worth of scheduled posts, it indicates + // that we have no more pending scheduled posts. So, we can break instead of making + // an additional database call as we know there are going to be no records in there. + break + } + } + + // once all scheduled posts are processed, we need to update and close the old ones + // as we don't process pending scheduled posts more than 24 hours old. + if err := a.Srv().Store().ScheduledPost().UpdateOldScheduledPosts(beforeTime); err != nil { + rctx.Logger().Error( + "App.ProcessScheduledPosts: failed to update old scheduled posts", + mlog.Int("before_time", beforeTime), + mlog.Err(err), + ) + } +} + +// processScheduledPostBatch processes one batch +func (a *App) processScheduledPostBatch(rctx request.CTX, scheduledPosts []*model.ScheduledPost) error { + rctx.Logger().Debug("processScheduledPostBatch called...") + var failedScheduledPosts []*model.ScheduledPost + var successfulScheduledPostIDs []string + + for i := range scheduledPosts { + rctx.Logger().Trace("processScheduledPostBatch processing scheduled post", mlog.String("scheduled_post_id", scheduledPosts[i].Id)) + scheduledPost, err := a.postScheduledPost(rctx, scheduledPosts[i]) + if err != nil { + rctx.Logger().Debug("processScheduledPostBatch scheduled post processing failed", mlog.String("scheduled_post_id", scheduledPosts[i].Id), mlog.Err(err)) + failedScheduledPosts = append(failedScheduledPosts, scheduledPost) + continue + } + + rctx.Logger().Trace("processScheduledPostBatch scheduled post processing successful", mlog.String("scheduled_post_id", scheduledPosts[i].Id)) + successfulScheduledPostIDs = append(successfulScheduledPostIDs, scheduledPost.Id) + } + + rctx.Logger().Trace("processScheduledPostBatch handling successful scheduled posts...", mlog.Int("count", len(successfulScheduledPostIDs))) + if err := a.handleSuccessfulScheduledPosts(rctx, successfulScheduledPostIDs); err != nil { + return errors.Wrap(err, "App.processScheduledPostBatch: failed to handle successfully posted scheduled posts") + } + + rctx.Logger().Trace("processScheduledPostBatch handling failed scheduled posts...", mlog.Int("count", len(failedScheduledPosts))) + a.handleFailedScheduledPosts(rctx, failedScheduledPosts) + rctx.Logger().Debug("processScheduledPostBatch finished...") + return nil +} + +// postScheduledPost processes an individual scheduled post +func (a *App) postScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + rctx.Logger().Debug("postScheduledPost called...", mlog.String("scheduled_post_id", scheduledPost.Id)) + + // we'll process scheduled posts one by one. + // If an error occurs, we'll log it and move onto the next scheduled post + + rctx.Logger().Trace("postScheduledPost fetching channel for scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", scheduledPost.ChannelId)) + channel, appErr := a.GetChannel(rctx, scheduledPost.ChannelId) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + rctx.Logger().Debug("postScheduledPost channel for scheduled post not found, setting error code", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", scheduledPost.ChannelId), mlog.String("error_code", model.ScheduledPostErrorCodeChannelNotFound)) + + scheduledPost.ErrorCode = model.ScheduledPostErrorCodeChannelNotFound + return scheduledPost, nil + } + + rctx.Logger().Error( + "App.processScheduledPostBatch: failed to get channel for scheduled post", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorUnknownError), + mlog.Err(appErr), + ) + + scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError + return scheduledPost, appErr + } + + rctx.Logger().Trace("postScheduledPost checking if scheduled post can be posted", mlog.String("scheduled_post_id", scheduledPost.Id)) + errorCode, err := a.canPostScheduledPost(rctx, scheduledPost, channel) + scheduledPost.ErrorCode = errorCode + if err != nil { + rctx.Logger().Error( + "App.processScheduledPostBatch: failed to check if scheduled post can be posted", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.Err(err), + ) + + return scheduledPost, err + } + + if scheduledPost.ErrorCode != "" { + rctx.Logger().Warn( + "App.processScheduledPostBatch: skipping posting a scheduled post as `can post` check failed", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", scheduledPost.ErrorCode), + ) + + return scheduledPost, fmt.Errorf("App.processScheduledPostBatch: skipping posting a scheduled post as `can post` check failed, error_code: %s", scheduledPost.ErrorCode) + } + + rctx.Logger().Trace("postScheduledPost converting scheduled post to post", mlog.String("scheduled_post_id", scheduledPost.Id)) + post, err := scheduledPost.ToPost() + if err != nil { + rctx.Logger().Error( + "App.processScheduledPostBatch: failed to convert scheduled post to a post", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("error_code", model.ScheduledPostErrorUnknownError), + mlog.Err(err), + ) + + scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError + return scheduledPost, err + } + + rctx.Logger().Trace("postScheduledPost posting the scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id)) + createPostFlags := model.CreatePostFlags{ + TriggerWebhooks: true, + SetOnline: false, + } + _, appErr = a.CreatePost(rctx, post, channel, createPostFlags) + if appErr != nil { + rctx.Logger().Error( + "App.processScheduledPostBatch: failed to post scheduled post", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorUnknownError), + mlog.Err(appErr), + ) + + scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError + return scheduledPost, appErr + } + + return scheduledPost, nil +} + +// canPostScheduledPost checks whether the scheduled post be created based on permissions and other checks. +func (a *App) canPostScheduledPost(rctx request.CTX, scheduledPost *model.ScheduledPost, channel *model.Channel) (string, error) { + rctx.Logger().Trace("canPostScheduledPost called...", mlog.String("scheduled_post_id", scheduledPost.Id)) + + user, appErr := a.GetUser(scheduledPost.UserId) + if appErr != nil { + if appErr.Id == MissingAccountError { + rctx.Logger().Debug("canPostScheduledPost user not found for scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("user_id", scheduledPost.UserId), mlog.String("error_code", model.ScheduledPostErrorCodeUserDoesNotExist)) + return model.ScheduledPostErrorCodeUserDoesNotExist, nil + } + + rctx.Logger().Error( + "App.canPostScheduledPost: failed to get user from database", + mlog.String("user_id", scheduledPost.UserId), + mlog.String("error_code", model.ScheduledPostErrorUnknownError), + mlog.Err(appErr), + ) + return model.ScheduledPostErrorUnknownError, errors.Wrapf(appErr, "App.canPostScheduledPost: failed to get user from database, userId: %s", scheduledPost.UserId) + } + + if user.DeleteAt != 0 { + rctx.Logger().Debug("canPostScheduledPost user for scheduled posts is deleted", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("user_id", scheduledPost.UserId), mlog.String("error_code", model.ScheduledPostErrorCodeUserDeleted)) + return model.ScheduledPostErrorCodeUserDeleted, nil + } + + if channel.DeleteAt != 0 { + rctx.Logger().Debug("canPostScheduledPost channel for scheduled post is archived", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("channel_id", channel.Id), mlog.String("error_code", model.ScheduledPostErrorCodeChannelArchived)) + return model.ScheduledPostErrorCodeChannelArchived, nil + } + + if scheduledPost.RootId != "" { + rootPosts, _, appErr := a.GetPostsByIds([]string{scheduledPost.RootId}) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + rctx.Logger().Debug("canPostScheduledPost thread root post for scheduled post is missing", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("root_post_id", scheduledPost.RootId), mlog.String("error_code", model.ScheduledPostErrorThreadDeleted)) + return model.ScheduledPostErrorThreadDeleted, nil + } + + rctx.Logger().Error( + "App.canPostScheduledPost: failed to get root post", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("root_post_id", scheduledPost.RootId), + mlog.String("error_code", model.ScheduledPostErrorUnknownError), + mlog.Err(appErr), + ) + + return model.ScheduledPostErrorUnknownError, errors.Wrapf(appErr, "App.canPostScheduledPost: failed to get root post, scheduled_post_id: %s, root_post_id: %s", scheduledPost.Id, scheduledPost.RootId) + } + + // you do get deleted posts from `GetPostsByIds`, so need to validate that as well + if len(rootPosts) == 1 && rootPosts[0].Id == scheduledPost.RootId && rootPosts[0].DeleteAt != 0 { + rctx.Logger().Debug("canPostScheduledPost thread root post is deleted", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.String("root_post_id", scheduledPost.RootId), mlog.String("error_code", model.ScheduledPostErrorThreadDeleted)) + return model.ScheduledPostErrorThreadDeleted, nil + } + } + + if appErr := userCreatePostPermissionCheckWithApp(rctx, a, scheduledPost.UserId, scheduledPost.ChannelId); appErr != nil { + rctx.Logger().Debug( + "canPostScheduledPost user does not have permission to create post in channel", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorCodeNoChannelPermission), + mlog.Err(appErr), + ) + return model.ScheduledPostErrorCodeNoChannelPermission, nil + } + + if appErr := PostHardenedModeCheckWithApp(a, false, scheduledPost.GetProps()); appErr != nil { + rctx.Logger().Debug( + "canPostScheduledPost hardened mode enabled: post contains props prohibited in hardened mode", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorInvalidPost), + mlog.Err(appErr), + ) + return model.ScheduledPostErrorInvalidPost, nil + } + + if appErr := PostPriorityCheckWithApp("ScheduledPostJob.postChecks", a, scheduledPost.UserId, scheduledPost.GetPriority(), scheduledPost.RootId); appErr != nil { + rctx.Logger().Debug( + "canPostScheduledPost post priority check failed", + mlog.String("scheduled_post_id", scheduledPost.Id), + mlog.String("user_id", scheduledPost.UserId), + mlog.String("channel_id", scheduledPost.ChannelId), + mlog.String("error_code", model.ScheduledPostErrorInvalidPost), + mlog.Err(appErr), + ) + return model.ScheduledPostErrorInvalidPost, nil + } + + rctx.Logger().Debug("canPostScheduledPost scheduled post can be posted", mlog.String("scheduled_post_id", scheduledPost.Id)) + return "", nil +} + +func (a *App) handleSuccessfulScheduledPosts(rctx request.CTX, successfulScheduledPostIDs []string) error { + if len(successfulScheduledPostIDs) > 0 { + // Successfully posted scheduled posts can be safely permanently deleted as no data is lost. + // The data is moved into the posts table. + err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts(successfulScheduledPostIDs) + if err != nil { + rctx.Logger().Error( + "App.handleSuccessfulScheduledPosts: failed to delete successfully posted scheduled posts", + mlog.Int("successfully_posted_count", len(successfulScheduledPostIDs)), + mlog.Err(err), + ) + return errors.Wrap(err, "App.handleSuccessfulScheduledPosts: failed to delete successfully posted scheduled posts") + } + } + + return nil +} + +func (a *App) handleFailedScheduledPosts(rctx request.CTX, failedScheduledPosts []*model.ScheduledPost) { + for _, failedScheduledPost := range failedScheduledPosts { + err := a.Srv().Store().ScheduledPost().UpdatedScheduledPost(failedScheduledPost) + if err != nil { + // we intentionally don't stop on error as its possible to continue updating other scheduled posts + rctx.Logger().Error( + "App.processScheduledPostBatch: failed to updated failed scheduled posts", + mlog.String("scheduled_post_id", failedScheduledPost.Id), + mlog.Err(err), + ) + } + } +} diff --git a/server/channels/app/scheduled_post_job_test.go b/server/channels/app/scheduled_post_job_test.go new file mode 100644 index 0000000000..10722e46a7 --- /dev/null +++ b/server/channels/app/scheduled_post_job_test.go @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestProcessScheduledPosts(t *testing.T) { + t.Run("base case - happy path", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + scheduledAt := model.GetMillis() + 1000 + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is second scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + + time.Sleep(1 * time.Second) + + th.App.ProcessScheduledPosts(th.Context) + + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + assert.NoError(t, err) + assert.Len(t, scheduledPosts, 0) + }) + + t.Run("sets error code for archived channel", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + appErr := th.App.DeleteChannel(th.Context, th.BasicChannel, th.BasicUser.Id) + assert.Nil(t, appErr) + + scheduledAt := model.GetMillis() - (5 * 60 * 60 * 1000) + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is second scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + + time.Sleep(1 * time.Second) + + th.App.ProcessScheduledPosts(th.Context) + + // since the channel ID we set in the above created scheduled posts is of a + // non-existing channel, the job should have set the appropriate error code for them in the database + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + assert.NoError(t, err) + assert.Len(t, scheduledPosts, 2) + + assert.Equal(t, model.ScheduledPostErrorCodeChannelArchived, scheduledPosts[0].ErrorCode) + assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0)) + + assert.Equal(t, model.ScheduledPostErrorCodeChannelArchived, scheduledPosts[1].ErrorCode) + assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0)) + }) + + t.Run("sets error code for archived user", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + scheduledAt := model.GetMillis() + 1000 + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is second scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + + _, appErr := th.App.UpdateActive(th.Context, th.BasicUser, false) + assert.Nil(t, appErr) + + defer func() { + _, _ = th.App.UpdateActive(th.Context, th.BasicUser, true) + }() + + time.Sleep(1 * time.Second) + + th.App.ProcessScheduledPosts(th.Context) + + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + assert.NoError(t, err) + assert.Len(t, scheduledPosts, 2) + + assert.Equal(t, model.ScheduledPostErrorCodeUserDeleted, scheduledPosts[0].ErrorCode) + assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0)) + + assert.Equal(t, model.ScheduledPostErrorCodeUserDeleted, scheduledPosts[1].ErrorCode) + assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0)) + }) + + t.Run("sets error code when user is not a channel member", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(getLicWithSkuShortName(model.LicenseShortSkuProfessional)) + + scheduledAt := model.GetMillis() + 1000 + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err := th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is second scheduled post", + }, + ScheduledAt: scheduledAt, + } + _, err = th.Server.Store().ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + + appErr := th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id) + assert.Nil(t, appErr) + + defer func() { + _ = th.App.JoinChannel(th.Context, th.BasicChannel, th.BasicUser.Id) + }() + + time.Sleep(1 * time.Second) + + th.App.ProcessScheduledPosts(th.Context) + + scheduledPosts, err := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicChannel.TeamId) + assert.NoError(t, err) + assert.Len(t, scheduledPosts, 2) + + assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[0].ErrorCode) + assert.Greater(t, scheduledPosts[0].ProcessedAt, int64(0)) + + assert.Equal(t, model.ScheduledPostErrorCodeNoChannelPermission, scheduledPosts[1].ErrorCode) + assert.Greater(t, scheduledPosts[1].ProcessedAt, int64(0)) + }) +} diff --git a/server/channels/app/scheduled_post_test.go b/server/channels/app/scheduled_post_test.go new file mode 100644 index 0000000000..9bf0547eca --- /dev/null +++ b/server/channels/app/scheduled_post_test.go @@ -0,0 +1,663 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestSaveScheduledPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("base case", func(t *testing.T) { + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + }) + + t.Run("cannot save invalid scheduled post", func(t *testing.T) { + scheduledPost := &model.ScheduledPost{ + // a completely empty scheduled post + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.NotNil(t, appErr) + require.Nil(t, createdScheduledPost) + }) + + t.Run("cannot save post scheduled in the past", func(t *testing.T) { + userId := model.NewId() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: "channel_id", + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() - 100000, // 100 seconds in the past + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.NotNil(t, appErr) + require.Nil(t, createdScheduledPost) + }) + + t.Run("cannot scheduled post in a channel you don't belong to", func(t *testing.T) { + userId := model.NewId() + + // we didn't create any channel member entry, so the user doesn't + // belong to the channel + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: "channel_id", + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.NotNil(t, appErr) + require.Nil(t, createdScheduledPost) + }) + + t.Run("cannot schedule post in an archived channel", func(t *testing.T) { + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + err = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.NotNil(t, appErr) + require.Nil(t, createdScheduledPost) + }) + + t.Run("can scheduled multiple posts in the same channel", func(t *testing.T) { + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + scheduledPost.Message = "this is a second scheduled post" + scheduledPost.Id = model.NewId() + createdScheduledPost, appErr = th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + }) + + t.Run("cannot save an empty post", func(t *testing.T) { + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.NotNil(t, appErr) + require.Nil(t, createdScheduledPost) + }) +} + +func TestGetUserTeamScheduledPosts(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("should get created scheduled posts", func(t *testing.T) { + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost1, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost1) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost1) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a second scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost2, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost2) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost2) + + defer func() { + _ = th.Server.Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPost1.Id, createdScheduledPost2.Id}) + }() + + retrievedScheduledPosts, appErr := th.App.GetUserTeamScheduledPosts(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) + require.Nil(t, appErr) + require.Equal(t, 2, len(retrievedScheduledPosts)) + + // more recently created scheduled post appears first in the list + require.Equal(t, createdScheduledPost1.Id, retrievedScheduledPosts[0].Id) + require.Equal(t, createdScheduledPost2.Id, retrievedScheduledPosts[1].Id) + }) + + t.Run("should handle no scheduled posts", func(t *testing.T) { + retrievedScheduledPosts, appErr := th.App.GetUserTeamScheduledPosts(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) + require.Nil(t, appErr) + require.Equal(t, 0, len(retrievedScheduledPosts)) + }) + + t.Run("should restrict to specified teams and DM/GMs", func(t *testing.T) { + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost1, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost1) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost1) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a second scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost2, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost2) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost2) + + defer func() { + _ = th.Server.Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPost1.Id, createdScheduledPost2.Id}) + }() + + // create a dummy team + secondTeam := th.CreateTeam() + _, appErr = th.App.JoinUserToTeam(th.Context, secondTeam, th.BasicUser, th.BasicUser.Id) + require.Nil(t, appErr) + + retrievedScheduledPosts, appErr := th.App.GetUserTeamScheduledPosts(th.Context, th.BasicUser.Id, secondTeam.Id) + require.Nil(t, appErr) + require.Equal(t, 0, len(retrievedScheduledPosts)) + }) + + t.Run("should not return scheduled posts from DMs and GMs when teamId is specified", func(t *testing.T) { + // start a DM between BasicUser1 and BasicUser2 + dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + + // create a GM. Since a GM needs at least 3 users, we'll create a third user first + thirdUser := th.CreateUser() + _, appErr = th.App.JoinUserToTeam(th.Context, th.BasicTeam, thirdUser, thirdUser.Id) + require.Nil(t, appErr) + + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, thirdUser.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: dm.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost1, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost1) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost1) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: gm.Id, + Message: "this is a second scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost2, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost2) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost2) + + defer func() { + _ = th.Server.Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPost1.Id, createdScheduledPost2.Id}) + }() + + retrievedScheduledPosts, appErr := th.App.GetUserTeamScheduledPosts(th.Context, th.BasicUser.Id, th.BasicChannel.TeamId) + require.Nil(t, appErr) + require.Equal(t, 0, len(retrievedScheduledPosts)) + }) + + t.Run("should return scheduled posts from DMs and GMs when teamId is empty", func(t *testing.T) { + // start a DM between BasicUser1 and BasicUser2 + dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) + require.Nil(t, appErr) + + // create a GM. Since a GM needs at least 3 users, we'll create a third user first + thirdUser := th.CreateUser() + _, appErr = th.App.JoinUserToTeam(th.Context, th.BasicTeam, thirdUser, thirdUser.Id) + require.Nil(t, appErr) + + gm, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, th.BasicUser2.Id, thirdUser.Id}, th.BasicUser.Id) + require.Nil(t, appErr) + + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: dm.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost1, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost1) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost1) + + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis() + 100, + UserId: th.BasicUser.Id, + ChannelId: gm.Id, + Message: "this is a second scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost2, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost2) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost2) + + defer func() { + _ = th.Server.Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPost1.Id, createdScheduledPost2.Id}) + }() + + retrievedScheduledPosts, appErr := th.App.GetUserTeamScheduledPosts(th.Context, th.BasicUser.Id, "") + require.Nil(t, appErr) + require.Equal(t, 2, len(retrievedScheduledPosts)) + + // more recently created scheduled post appears first in the list + require.Equal(t, createdScheduledPost1.Id, retrievedScheduledPosts[0].Id) + require.Equal(t, createdScheduledPost2.Id, retrievedScheduledPosts[1].Id) + }) +} + +func TestUpdateScheduledPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("base case", func(t *testing.T) { + // first we'll create a scheduled post + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + // now we'll try updating it + newScheduledAtTime := model.GetMillis() + 9999999 + createdScheduledPost.ScheduledAt = newScheduledAtTime + createdScheduledPost.Message = "Updated Message!!!" + + updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, userId, createdScheduledPost) + require.Nil(t, appErr) + require.NotNil(t, updatedScheduledPost) + + require.Equal(t, newScheduledAtTime, updatedScheduledPost.ScheduledAt) + require.Equal(t, "Updated Message!!!", updatedScheduledPost.Message) + }) + + t.Run("should ot be allowed to updated a scheduled post not belonging to the user", func(t *testing.T) { + // first we'll create a scheduled post + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + // now we'll try updating it + newScheduledAtTime := model.GetMillis() + 9999999 + createdScheduledPost.ScheduledAt = newScheduledAtTime + createdScheduledPost.Message = "Updated Message!!!" + + updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser2.Id, createdScheduledPost) + require.NotNil(t, appErr) + require.Equal(t, http.StatusForbidden, appErr.StatusCode) + require.Nil(t, updatedScheduledPost) + }) + + t.Run("should only allow updating limited fields", func(t *testing.T) { + // first we'll create a scheduled post + userId := model.NewId() + + channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ + Name: model.NewId(), + DisplayName: "Channel", + Type: model.ChannelTypeOpen, + }, 1000) + require.NoError(t, err) + + _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ + ChannelId: channel.Id, + UserId: userId, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: false, + SchemeUser: true, + }) + require.NoError(t, err) + + defer func() { + _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) + _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) + }() + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: channel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + // now we'll try updating it + newUpdatedAt := model.GetMillis() + 1000000 + createdScheduledPost.UpdateAt = newUpdatedAt // this should be overridden by the actual update time + createdScheduledPost.Message = "Updated Message" // this will update + newChannelId := model.NewId() + createdScheduledPost.ChannelId = newChannelId // this won't update + newCreateAt := model.GetMillis() + 5000000 + createdScheduledPost.CreateAt = newCreateAt // this won't update + createdScheduledPost.FileIds = []string{model.NewId(), model.NewId()} + createdScheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError + + updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, userId, createdScheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + require.NotEqual(t, newUpdatedAt, updatedScheduledPost.UpdateAt) + require.Equal(t, "Updated Message", updatedScheduledPost.Message) + require.NotEqual(t, newChannelId, updatedScheduledPost.ChannelId) + require.NotEqual(t, newCreateAt, updatedScheduledPost.CreateAt) + require.Equal(t, 2, len(updatedScheduledPost.FileIds)) + require.Equal(t, model.ScheduledPostErrorUnknownError, createdScheduledPost.ErrorCode) + }) +} + +func TestDeleteScheduledPost(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("base case", func(t *testing.T) { + // first we'll create a scheduled post + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedScheduledPost) + require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id) + require.Equal(t, createdScheduledPost.Message, fetchedScheduledPost.Message) + + // now we'll delete it + var deletedScheduledPost *model.ScheduledPost + deletedScheduledPost, appErr = th.App.DeleteScheduledPost(th.Context, th.BasicUser.Id, scheduledPost.Id, "connection_id") + require.Nil(t, appErr) + require.NotNil(t, deletedScheduledPost) + + require.Equal(t, scheduledPost.Id, deletedScheduledPost.Id) + require.Equal(t, scheduledPost.Message, deletedScheduledPost.Message) + + // try to fetch it again + reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + require.Error(t, err) // This will produce error as the row doesn't exist + require.Nil(t, reFetchedScheduledPost) + }) + + t.Run("should not allow deleting someone else's scheduled post", func(t *testing.T) { + // first we'll create a scheduled post + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost) + require.Nil(t, appErr) + require.NotNil(t, createdScheduledPost) + + fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedScheduledPost) + require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id) + require.Equal(t, createdScheduledPost.Message, fetchedScheduledPost.Message) + + // now we'll delete it + var deletedScheduledPost *model.ScheduledPost + deletedScheduledPost, appErr = th.App.DeleteScheduledPost(th.Context, th.BasicUser2.Id, scheduledPost.Id, "connection_id") + require.NotNil(t, appErr) + require.Nil(t, deletedScheduledPost) + + // try to fetch it again + reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, reFetchedScheduledPost) + require.Equal(t, createdScheduledPost.Id, reFetchedScheduledPost.Id) + require.Equal(t, createdScheduledPost.Message, reFetchedScheduledPost.Message) + }) + + t.Run("should producer error when deleting non existing scheduled post", func(t *testing.T) { + var deletedScheduledPost *model.ScheduledPost + deletedScheduledPost, appErr := th.App.DeleteScheduledPost(th.Context, th.BasicUser.Id, model.NewId(), "connection_id") + require.NotNil(t, appErr) + require.Nil(t, deletedScheduledPost) + }) +} diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 03808e8a62..ca3abe38d0 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -81,6 +81,10 @@ import ( "github.com/mattermost/mattermost/server/v8/platform/shared/templates" ) +const ( + scheduledPostJobInterval = 5 * time.Minute +) + var SentryDSN = "https://9d7c9cccf549479799f880bcf4f26323@o94110.ingest.sentry.io/5212327" // This is a placeholder to allow the existing release pipelines to run without failing to insert @@ -506,6 +510,7 @@ func (s *Server) runJobs() { appInstance := New(ServerConnector(s.Channels())) runDNDStatusExpireJob(appInstance) runPostReminderJob(appInstance) + runScheduledPostJob(appInstance) }) s.Go(func() { runSecurityJob(s) @@ -1819,6 +1824,30 @@ func runPostReminderJob(a *App) { }) } +func runScheduledPostJob(a *App) { + if a.IsLeader() { + doRunScheduledPostJob(a) + } + + a.ch.srv.AddClusterLeaderChangedListener(func() { + mlog.Info("Cluster leader changed. Determining if scheduled posts task should be running", mlog.Bool("isLeader", a.IsLeader())) + if a.IsLeader() { + doRunScheduledPostJob(a) + } else { + mlog.Info("This is no longer leader node. Cancelling the scheduled post task", mlog.Bool("isLeader", a.IsLeader())) + cancelTask(&a.ch.scheduledPostMut, &a.ch.scheduledPostTask) + } + }) +} + +func doRunScheduledPostJob(a *App) { + rctx := request.EmptyContext(a.Log()) + withMut(&a.ch.scheduledPostMut, func() { + fn := func() { a.ProcessScheduledPosts(rctx) } + a.ch.scheduledPostTask = model.CreateRecurringTaskFromNextIntervalTime("Process Scheduled Posts", fn, scheduledPostJobInterval) + }) +} + func (a *App) GetAppliedSchemaMigrations() ([]model.AppliedMigration, *model.AppError) { table, err := a.Srv().Store().GetAppliedMigrations() if err != nil { diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 9c1ebdd5b0..dc3b4b1f5f 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -251,6 +251,8 @@ channels/db/migrations/mysql/000126_sharedchannels_remotes_add_deleteat.down.sql channels/db/migrations/mysql/000126_sharedchannels_remotes_add_deleteat.up.sql channels/db/migrations/mysql/000127_add_mfa_used_ts_to_users.down.sql channels/db/migrations/mysql/000127_add_mfa_used_ts_to_users.up.sql +channels/db/migrations/mysql/000128_create_scheduled_posts.down.sql +channels/db/migrations/mysql/000128_create_scheduled_posts.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -503,3 +505,5 @@ channels/db/migrations/postgres/000126_sharedchannels_remotes_add_deleteat.down. channels/db/migrations/postgres/000126_sharedchannels_remotes_add_deleteat.up.sql channels/db/migrations/postgres/000127_add_mfa_used_ts_to_users.down.sql channels/db/migrations/postgres/000127_add_mfa_used_ts_to_users.up.sql +channels/db/migrations/postgres/000128_create_scheduled_posts.down.sql +channels/db/migrations/postgres/000128_create_scheduled_posts.up.sql diff --git a/server/channels/db/migrations/mysql/000128_create_scheduled_posts.down.sql b/server/channels/db/migrations/mysql/000128_create_scheduled_posts.down.sql new file mode 100644 index 0000000000..a09fd376ae --- /dev/null +++ b/server/channels/db/migrations/mysql/000128_create_scheduled_posts.down.sql @@ -0,0 +1,15 @@ +DROP TABLE IF EXISTS scheduledposts; + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ScheduledPosts' + AND table_schema = DATABASE() + AND index_name = 'idx_scheduledposts_userid_channel_id_scheduled_at' + ) > 0, + 'DROP INDEX idx_scheduledposts_userid_channel_id_scheduled_at on ScheduledPosts;', + 'SELECT 1;' + )); +PREPARE removeIndexIfExists FROM @preparedStatement; +EXECUTE removeIndexIfExists; +DEALLOCATE PREPARE removeIndexIfExists; diff --git a/server/channels/db/migrations/mysql/000128_create_scheduled_posts.up.sql b/server/channels/db/migrations/mysql/000128_create_scheduled_posts.up.sql new file mode 100644 index 0000000000..021edd5d06 --- /dev/null +++ b/server/channels/db/migrations/mysql/000128_create_scheduled_posts.up.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS ScheduledPosts ( + id VARCHAR(26) PRIMARY KEY, + createat bigint(20), + updateat bigint(20), + userid VARCHAR(26) NOT NULL, + channelid VARCHAR(26) NOT NULL, + rootid VARCHAR(26), + message text, + props text, + fileids text, + priority text, + scheduledat bigint(20) NOT NULL, + processedat bigint(20), + errorcode VARCHAR(200) +); + +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_name = 'ScheduledPosts' + AND table_schema = DATABASE() + AND index_name = 'idx_scheduledposts_userid_channel_id_scheduled_at' + ) > 0, + 'SELECT 1', + 'CREATE INDEX idx_scheduledposts_userid_channel_id_scheduled_at ON ScheduledPosts (UserId, ChannelId, ScheduledAt);' + )); +PREPARE createIndexIfNotExists FROM @preparedStatement; +EXECUTE createIndexIfNotExists; +DEALLOCATE PREPARE createIndexIfNotExists; diff --git a/server/channels/db/migrations/postgres/000128_create_scheduled_posts.down.sql b/server/channels/db/migrations/postgres/000128_create_scheduled_posts.down.sql new file mode 100644 index 0000000000..d1ee90a9fb --- /dev/null +++ b/server/channels/db/migrations/postgres/000128_create_scheduled_posts.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS scheduledposts; +DROP INDEX IF EXISTS idx_scheduledposts_userid_channel_id_scheduled_at; diff --git a/server/channels/db/migrations/postgres/000128_create_scheduled_posts.up.sql b/server/channels/db/migrations/postgres/000128_create_scheduled_posts.up.sql new file mode 100644 index 0000000000..76c6907cd2 --- /dev/null +++ b/server/channels/db/migrations/postgres/000128_create_scheduled_posts.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS scheduledposts ( + id VARCHAR(26) PRIMARY KEY, + createat bigint, + updateat bigint, + userid VARCHAR(26) NOT NULL, + channelid VARCHAR(26) NOT NULL, + rootid VARCHAR(26), + message VARCHAR(65535), + props VARCHAR(8000), + fileids VARCHAR(300), + priority text, + scheduledat bigint NOT NULL, + processedat bigint, + errorcode VARCHAR(200) +); + +CREATE INDEX IF NOT EXISTS idx_scheduledposts_userid_channel_id_scheduled_at ON ScheduledPosts (UserId, ChannelId, ScheduledAt); diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 0dde285eba..8748c0648f 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -50,6 +50,7 @@ type OpenTracingLayer struct { RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore RoleStore store.RoleStore + ScheduledPostStore store.ScheduledPostStore SchemeStore store.SchemeStore SessionStore store.SessionStore SharedChannelStore store.SharedChannelStore @@ -190,6 +191,10 @@ func (s *OpenTracingLayer) Role() store.RoleStore { return s.RoleStore } +func (s *OpenTracingLayer) ScheduledPost() store.ScheduledPostStore { + return s.ScheduledPostStore +} + func (s *OpenTracingLayer) Scheme() store.SchemeStore { return s.SchemeStore } @@ -401,6 +406,11 @@ type OpenTracingLayerRoleStore struct { Root *OpenTracingLayer } +type OpenTracingLayerScheduledPostStore struct { + store.ScheduledPostStore + Root *OpenTracingLayer +} + type OpenTracingLayerSchemeStore struct { store.SchemeStore Root *OpenTracingLayer @@ -8602,6 +8612,145 @@ func (s *OpenTracingLayerRoleStore) Save(role *model.Role) (*model.Role, error) return result, err } +func (s *OpenTracingLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.CreateScheduledPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ScheduledPostStore.CreateScheduledPost(scheduledPost) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ScheduledPostStore.Get(scheduledPostId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerScheduledPostStore) GetMaxMessageSize() int { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.GetMaxMessageSize") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result := s.ScheduledPostStore.GetMaxMessageSize() + return result +} + +func (s *OpenTracingLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.GetPendingScheduledPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ScheduledPostStore.GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.GetScheduledPostsForUser") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ScheduledPostStore.GetScheduledPostsForUser(userId, teamId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.PermanentlyDeleteScheduledPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ScheduledPostStore.PermanentlyDeleteScheduledPosts(scheduledPostIDs) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.UpdateOldScheduledPosts") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ScheduledPostStore.UpdateOldScheduledPosts(beforeTime) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ScheduledPostStore.UpdatedScheduledPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ScheduledPostStore.UpdatedScheduledPost(scheduledPost) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerSchemeStore) CountByScope(scope string) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "SchemeStore.CountByScope") @@ -13672,6 +13821,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.RemoteClusterStore = &OpenTracingLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &OpenTracingLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} newStore.RoleStore = &OpenTracingLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore} + newStore.ScheduledPostStore = &OpenTracingLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore} newStore.SchemeStore = &OpenTracingLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore} newStore.SessionStore = &OpenTracingLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore} newStore.SharedChannelStore = &OpenTracingLayerSharedChannelStore{SharedChannelStore: childStore.SharedChannel(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 2c7741e96c..bf8fa85b48 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -54,6 +54,7 @@ type RetryLayer struct { RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore RoleStore store.RoleStore + ScheduledPostStore store.ScheduledPostStore SchemeStore store.SchemeStore SessionStore store.SessionStore SharedChannelStore store.SharedChannelStore @@ -194,6 +195,10 @@ func (s *RetryLayer) Role() store.RoleStore { return s.RoleStore } +func (s *RetryLayer) ScheduledPost() store.ScheduledPostStore { + return s.ScheduledPostStore +} + func (s *RetryLayer) Scheme() store.SchemeStore { return s.SchemeStore } @@ -405,6 +410,11 @@ type RetryLayerRoleStore struct { Root *RetryLayer } +type RetryLayerScheduledPostStore struct { + store.ScheduledPostStore + Root *RetryLayer +} + type RetryLayerSchemeStore struct { store.SchemeStore Root *RetryLayer @@ -9812,6 +9822,159 @@ func (s *RetryLayerRoleStore) Save(role *model.Role) (*model.Role, error) { } +func (s *RetryLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + + tries := 0 + for { + result, err := s.ScheduledPostStore.CreateScheduledPost(scheduledPost) + 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 *RetryLayerScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { + + tries := 0 + for { + result, err := s.ScheduledPostStore.Get(scheduledPostId) + 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 *RetryLayerScheduledPostStore) GetMaxMessageSize() int { + + return s.ScheduledPostStore.GetMaxMessageSize() + +} + +func (s *RetryLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + + tries := 0 + for { + result, err := s.ScheduledPostStore.GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + 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 *RetryLayerScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { + + tries := 0 + for { + result, err := s.ScheduledPostStore.GetScheduledPostsForUser(userId, teamId) + 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 *RetryLayerScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error { + + tries := 0 + for { + err := s.ScheduledPostStore.PermanentlyDeleteScheduledPosts(scheduledPostIDs) + 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 *RetryLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { + + tries := 0 + for { + err := s.ScheduledPostStore.UpdateOldScheduledPosts(beforeTime) + 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 *RetryLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { + + tries := 0 + for { + err := s.ScheduledPostStore.UpdatedScheduledPost(scheduledPost) + 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 *RetryLayerSchemeStore) CountByScope(scope string) (int64, error) { tries := 0 @@ -15605,6 +15768,7 @@ func New(childStore store.Store) *RetryLayer { newStore.RemoteClusterStore = &RetryLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &RetryLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} newStore.RoleStore = &RetryLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore} + newStore.ScheduledPostStore = &RetryLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore} newStore.SchemeStore = &RetryLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore} newStore.SessionStore = &RetryLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore} newStore.SharedChannelStore = &RetryLayerSharedChannelStore{SharedChannelStore: childStore.SharedChannel(), Root: &newStore} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 49102bd73a..50844e7cb7 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -62,6 +62,7 @@ func genStore() *mocks.Store { mock.On("PostPersistentNotification").Return(&mocks.PostPersistentNotificationStore{}) mock.On("DesktopTokens").Return(&mocks.DesktopTokensStore{}) mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{}) + mock.On("ScheduledPost").Return(&mocks.ScheduledPostStore{}) return mock } diff --git a/server/channels/store/sqlstore/scheduled_post_store.go b/server/channels/store/sqlstore/scheduled_post_store.go new file mode 100644 index 0000000000..2bde8c52e4 --- /dev/null +++ b/server/channels/store/sqlstore/scheduled_post_store.go @@ -0,0 +1,289 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "strings" + "sync" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +type SqlScheduledPostStore struct { + *SqlStore + maxMessageSizeOnce sync.Once + maxMessageSizeCached int +} + +func newScheduledPostStore(sqlStore *SqlStore) *SqlScheduledPostStore { + return &SqlScheduledPostStore{ + SqlStore: sqlStore, + maxMessageSizeCached: model.PostMessageMaxRunesV2, + } +} + +func (s *SqlScheduledPostStore) columns(prefix string) []string { + if prefix != "" && !strings.HasSuffix(prefix, ".") { + prefix = prefix + "." + } + + return []string{ + prefix + "Id", + prefix + "CreateAt", + prefix + "UpdateAt", + prefix + "UserId", + prefix + "ChannelId", + prefix + "RootId", + prefix + "Message", + prefix + "Props", + prefix + "FileIds", + prefix + "Priority", + prefix + "ScheduledAt", + prefix + "ProcessedAt", + prefix + "ErrorCode", + } +} + +func (s *SqlScheduledPostStore) scheduledPostToSlice(scheduledPost *model.ScheduledPost) []interface{} { + return []interface{}{ + scheduledPost.Id, + scheduledPost.CreateAt, + scheduledPost.UpdateAt, + scheduledPost.UserId, + scheduledPost.ChannelId, + scheduledPost.RootId, + scheduledPost.Message, + model.StringInterfaceToJSON(scheduledPost.GetProps()), + model.ArrayToJSON(scheduledPost.FileIds), + model.StringInterfaceToJSON(scheduledPost.Priority), + scheduledPost.ScheduledAt, + scheduledPost.ProcessedAt, + scheduledPost.ErrorCode, + } +} + +func (s *SqlScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + scheduledPost.PreSave() + + builder := s.getQueryBuilder(). + Insert("ScheduledPosts"). + Columns(s.columns("")...). + Values(s.scheduledPostToSlice(scheduledPost)...) + + query, args, err := builder.ToSql() + if err != nil { + mlog.Error("SqlScheduledPostStore.CreateScheduledPost failed to generate SQL from query builder", mlog.Err(err)) + return nil, errors.Wrap(err, "SqlScheduledPostStore.CreateScheduledPost failed to generate SQL from query builder") + } + + if _, err := s.GetMasterX().Exec(query, args...); err != nil { + mlog.Error("SqlScheduledPostStore.CreateScheduledPost failed to insert scheduled post", mlog.Err(err)) + return nil, errors.Wrap(err, "SqlScheduledPostStore.CreateScheduledPost failed to insert scheduled post") + } + + return scheduledPost, nil +} + +func (s *SqlScheduledPostStore) GetScheduledPostsForUser(userId, teamId string) ([]*model.ScheduledPost, error) { + // return scheduled posts for this user for + // specified team. + // + //An empty teamId fetches scheduled posts belonging to + // DMs and GMs (DMs and GMs do not belong to any team + + // We're intentionally including scheduled posts from archived channels, + // or channels the user no longer belongs to as we want to still show those + // scheduled posts with appropriate error to the user. + // This is why we're not joining with ChannelMembers, and directly + // joining with Channels table. + + query := s.getQueryBuilder(). + Select(s.columns("sp")...). + From("ScheduledPosts AS sp"). + InnerJoin("Channels as c on sp.ChannelId = c.Id"). + Where(sq.Eq{ + "sp.UserId": userId, + "c.TeamId": teamId, + }). + OrderBy("sp.ScheduledAt, sp.CreateAt") + + var scheduledPosts []*model.ScheduledPost + + if err := s.GetReplicaX().SelectBuilder(&scheduledPosts, query); err != nil { + mlog.Error("SqlScheduledPostStore.GetScheduledPostsForUser: failed to fetch scheduled posts for user", mlog.String("user_id", userId), mlog.String("team_id", teamId), mlog.Err(err)) + + return nil, errors.Wrapf(err, "SqlScheduledPostStore.GetScheduledPostsForUser: failed to fetch scheduled posts for user, userId: %s, teamID: %s", userId, teamId) + } + + return scheduledPosts, nil +} + +func (s *SqlScheduledPostStore) GetMaxMessageSize() int { + s.maxMessageSizeOnce.Do(func() { + var err error + s.maxMessageSizeCached, err = s.SqlStore.determineMaxColumnSize("ScheduledPosts", "Message") + if err != nil { + mlog.Error("SqlScheduledPostStore.getMaxMessageSize: error occurred during determining max column size for ScheduledPosts.Message column", mlog.Err(err)) + return + } + }) + + return s.maxMessageSizeCached +} + +func (s *SqlScheduledPostStore) GetPendingScheduledPosts(beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + query := s.getQueryBuilder(). + Select(s.columns("")...). + From("ScheduledPosts"). + Where(sq.Eq{"ErrorCode": ""}). + OrderBy("ScheduledAt DESC", "Id"). + Limit(perPage) + + if lastScheduledPostId == "" { + query = query.Where(sq.And{ + sq.LtOrEq{"ScheduledAt": beforeTime}, + sq.GtOrEq{"ScheduledAt": afterTime}, + }) + } + if lastScheduledPostId != "" { + query = query. + Where(sq.Or{ + sq.And{ + sq.LtOrEq{"ScheduledAt": beforeTime}, + sq.GtOrEq{"ScheduledAt": afterTime}, + }, + sq.And{ + sq.Eq{"ScheduledAt": beforeTime}, + sq.Gt{"Id": lastScheduledPostId}, + }, + }) + } + + var scheduledPosts []*model.ScheduledPost + if err := s.GetReplicaX().SelectBuilder(&scheduledPosts, query); err != nil { + mlog.Error( + "SqlScheduledPostStore.GetPendingScheduledPosts: failed to fetch pending scheduled posts for processing", + mlog.Int("before_time", beforeTime), + mlog.String("last_scheduled_post_id", lastScheduledPostId), + mlog.Uint("items_per_page", perPage), mlog.Err(err), + ) + + return nil, errors.Wrapf( + err, + "SqlScheduledPostStore.GetPendingScheduledPosts: failed to fetch pending scheduled posts for processing, before_time: %d, last_scheduled_post_id: %s, items_per_page: %d", + beforeTime, lastScheduledPostId, perPage, + ) + } + + return scheduledPosts, nil +} + +func (s *SqlScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error { + if len(scheduledPostIDs) == 0 { + return nil + } + + query := s.getQueryBuilder(). + Delete("ScheduledPosts"). + Where(sq.Eq{"Id": scheduledPostIDs}) + + sql, params, err := query.ToSql() + if err != nil { + errToReturn := errors.Wrapf(err, "PermanentlyDeleteScheduledPosts: failed to generate SQL query for permanently deleting batch of scheduled posts") + s.Logger().Error(errToReturn.Error()) + return errToReturn + } + + if _, err := s.GetMasterX().Exec(sql, params...); err != nil { + errToReturn := errors.Wrapf(err, "PermanentlyDeleteScheduledPosts: failed to delete batch of scheduled posts from database") + s.Logger().Error(errToReturn.Error()) + return errToReturn + } + + return nil +} + +func (s *SqlScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { + scheduledPost.PreUpdate() + + builder := s.getQueryBuilder(). + Update("ScheduledPosts"). + SetMap(s.toUpdateMap(scheduledPost)). + Where(sq.Eq{"Id": scheduledPost.Id}) + + query, args, err := builder.ToSql() + if err != nil { + mlog.Error("SqlScheduledPostStore.UpdatedScheduledPost failed to generate SQL from updating scheduled posts", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) + return errors.Wrap(err, "SqlScheduledPostStore.UpdatedScheduledPost failed to generate SQL from bulk updating scheduled posts") + } + + _, err = s.GetMasterX().Exec(query, args...) + if err != nil { + mlog.Error("SqlScheduledPostStore.UpdatedScheduledPost failed to update scheduled post", mlog.String("scheduled_post_id", scheduledPost.Id), mlog.Err(err)) + return errors.Wrap(err, "SqlScheduledPostStore.UpdatedScheduledPost failed to update scheduled post") + } + + return nil +} + +func (s *SqlScheduledPostStore) toUpdateMap(scheduledPost *model.ScheduledPost) map[string]interface{} { + now := model.GetMillis() + return map[string]interface{}{ + "UpdateAt": now, + "Message": scheduledPost.Message, + "Props": model.StringInterfaceToJSON(scheduledPost.GetProps()), + "FileIds": model.ArrayToJSON(scheduledPost.FileIds), + "Priority": model.StringInterfaceToJSON(scheduledPost.Priority), + "ScheduledAt": scheduledPost.ScheduledAt, + "ProcessedAt": now, + "ErrorCode": scheduledPost.ErrorCode, + } +} + +func (s *SqlScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { + query := s.getQueryBuilder(). + Select(s.columns("")...). + From("ScheduledPosts"). + Where(sq.Eq{ + "Id": scheduledPostId, + }) + + scheduledPost := &model.ScheduledPost{} + + if err := s.GetReplicaX().GetBuilder(scheduledPost, query); err != nil { + mlog.Error("SqlScheduledPostStore.Get: failed to get single scheduled post by ID from database", mlog.String("scheduled_post_id", scheduledPostId), mlog.Err(err)) + + return nil, errors.Wrapf(err, "SqlScheduledPostStore.Get: failed to get single scheduled post by ID from database, scheduledPostId: %s", scheduledPostId) + } + + return scheduledPost, nil +} + +func (s *SqlScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { + builder := s.getQueryBuilder(). + Update("ScheduledPosts"). + Set("ErrorCode", model.ScheduledPostErrorUnableToSend). + Set("ProcessedAt", model.GetMillis()). + Where(sq.And{ + sq.Eq{"ErrorCode": ""}, + sq.Lt{"ScheduledAt": beforeTime}, + }) + + query, args, err := builder.ToSql() + if err != nil { + mlog.Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to generate SQL from updating old scheduled posts", mlog.Err(err)) + return errors.Wrap(err, "SqlScheduledPostStore.UpdateOldScheduledPosts failed to generate SQL from updating old scheduled posts") + } + + _, err = s.GetMasterX().Exec(query, args...) + if err != nil { + mlog.Error("SqlScheduledPostStore.UpdateOldScheduledPosts failed to update old scheduled posts", mlog.Err(err)) + return errors.Wrap(err, "SqlScheduledPostStore.UpdateOldScheduledPosts failed to update old scheduled posts") + } + + return nil +} diff --git a/server/channels/store/sqlstore/scheduled_post_store_test.go b/server/channels/store/sqlstore/scheduled_post_store_test.go new file mode 100644 index 0000000000..7365982d06 --- /dev/null +++ b/server/channels/store/sqlstore/scheduled_post_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/v8/channels/store/storetest" +) + +func TestScheduledPostStore(t *testing.T) { + StoreTestWithSqlStore(t, storetest.TestScheduledPostStore) +} diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index dabeaa3cd0..b09690992a 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -111,6 +111,7 @@ type SqlStoreStores struct { postPersistentNotification store.PostPersistentNotificationStore desktopTokens store.DesktopTokensStore channelBookmarks store.ChannelBookmarkStore + scheduledPost store.ScheduledPostStore } type SqlStore struct { @@ -236,6 +237,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store) store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics) store.stores.channelBookmarks = newSqlChannelBookmarkStore(store) + store.stores.scheduledPost = newScheduledPostStore(store) store.stores.preference.(*SqlPreferenceStore).deleteUnusedFeatures() @@ -1294,3 +1296,51 @@ func (ss *SqlStore) GetAppliedMigrations() ([]model.AppliedMigration, error) { return migrations, nil } + +func (ss *SqlStore) determineMaxColumnSize(tableName, columnName string) (int, error) { + var columnSizeBytes int32 + ss.getQueryPlaceholder() + + if ss.DriverName() == model.DatabaseDriverPostgres { + if err := ss.GetReplicaX().Get(&columnSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + lower(table_name) = lower($1) + AND lower(column_name) = lower($2) + `, tableName, columnName); err != nil { + mlog.Warn("Unable to determine the maximum supported column size for Postgres", mlog.Err(err)) + return 0, err + } + } else if ss.DriverName() == model.DatabaseDriverMysql { + if err := ss.GetReplicaX().Get(&columnSizeBytes, ` + SELECT + COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) + FROM + INFORMATION_SCHEMA.COLUMNS + WHERE + table_schema = DATABASE() + AND lower(table_name) = lower(?) + AND lower(column_name) = lower(?) + LIMIT 0, 1 + `, tableName, columnName); err != nil { + mlog.Warn("Unable to determine the maximum supported column size for MySQL", mlog.Err(err)) + return 0, err + } + } else { + mlog.Warn("No implementation found to determine the maximum supported column size") + } + + // Assume a worst-case representation of four bytes per rune. + maxColumnSize := int(columnSizeBytes) / 4 + + mlog.Info("Column has size restrictions", mlog.String("table_name", tableName), mlog.String("column_name", columnName), mlog.Int("max_characters", maxColumnSize), mlog.Int("max_bytes", columnSizeBytes)) + + return maxColumnSize, nil +} + +func (ss *SqlStore) ScheduledPost() store.ScheduledPostStore { + return ss.stores.scheduledPost +} diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 9fc32bc231..592e815d4f 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -91,6 +91,7 @@ type Store interface { PostPersistentNotification() PostPersistentNotificationStore DesktopTokens() DesktopTokensStore ChannelBookmark() ChannelBookmarkStore + ScheduledPost() ScheduledPostStore } type RetentionPolicyStore interface { @@ -1055,6 +1056,17 @@ type ChannelBookmarkStore interface { GetBookmarksForChannelSince(channelID string, since int64) ([]*model.ChannelBookmarkWithFileInfo, error) } +type ScheduledPostStore interface { + GetMaxMessageSize() int + CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) + GetScheduledPostsForUser(userId, teamId string) ([]*model.ScheduledPost, error) + GetPendingScheduledPosts(beforeTime, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) + PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error + UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error + Get(scheduledPostId string) (*model.ScheduledPost, error) + UpdateOldScheduledPosts(beforeTime int64) error +} + // ChannelSearchOpts contains options for searching channels. // // NotAssociatedToGroup will exclude channels that have associated, active GroupChannels records. diff --git a/server/channels/store/storetest/mocks/ScheduledPostStore.go b/server/channels/store/storetest/mocks/ScheduledPostStore.go new file mode 100644 index 0000000000..5ad10df3b0 --- /dev/null +++ b/server/channels/store/storetest/mocks/ScheduledPostStore.go @@ -0,0 +1,221 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// ScheduledPostStore is an autogenerated mock type for the ScheduledPostStore type +type ScheduledPostStore struct { + mock.Mock +} + +// CreateScheduledPost provides a mock function with given fields: scheduledPost +func (_m *ScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + ret := _m.Called(scheduledPost) + + if len(ret) == 0 { + panic("no return value specified for CreateScheduledPost") + } + + var r0 *model.ScheduledPost + var r1 error + if rf, ok := ret.Get(0).(func(*model.ScheduledPost) (*model.ScheduledPost, error)); ok { + return rf(scheduledPost) + } + if rf, ok := ret.Get(0).(func(*model.ScheduledPost) *model.ScheduledPost); ok { + r0 = rf(scheduledPost) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledPost) + } + } + + if rf, ok := ret.Get(1).(func(*model.ScheduledPost) error); ok { + r1 = rf(scheduledPost) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: scheduledPostId +func (_m *ScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { + ret := _m.Called(scheduledPostId) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *model.ScheduledPost + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.ScheduledPost, error)); ok { + return rf(scheduledPostId) + } + if rf, ok := ret.Get(0).(func(string) *model.ScheduledPost); ok { + r0 = rf(scheduledPostId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ScheduledPost) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(scheduledPostId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetMaxMessageSize provides a mock function with given fields: +func (_m *ScheduledPostStore) GetMaxMessageSize() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetMaxMessageSize") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetPendingScheduledPosts provides a mock function with given fields: beforeTime, afterTime, lastScheduledPostId, perPage +func (_m *ScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + ret := _m.Called(beforeTime, afterTime, lastScheduledPostId, perPage) + + if len(ret) == 0 { + panic("no return value specified for GetPendingScheduledPosts") + } + + var r0 []*model.ScheduledPost + var r1 error + if rf, ok := ret.Get(0).(func(int64, int64, string, uint64) ([]*model.ScheduledPost, error)); ok { + return rf(beforeTime, afterTime, lastScheduledPostId, perPage) + } + if rf, ok := ret.Get(0).(func(int64, int64, string, uint64) []*model.ScheduledPost); ok { + r0 = rf(beforeTime, afterTime, lastScheduledPostId, perPage) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ScheduledPost) + } + } + + if rf, ok := ret.Get(1).(func(int64, int64, string, uint64) error); ok { + r1 = rf(beforeTime, afterTime, lastScheduledPostId, perPage) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetScheduledPostsForUser provides a mock function with given fields: userId, teamId +func (_m *ScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { + ret := _m.Called(userId, teamId) + + if len(ret) == 0 { + panic("no return value specified for GetScheduledPostsForUser") + } + + var r0 []*model.ScheduledPost + var r1 error + if rf, ok := ret.Get(0).(func(string, string) ([]*model.ScheduledPost, error)); ok { + return rf(userId, teamId) + } + if rf, ok := ret.Get(0).(func(string, string) []*model.ScheduledPost); ok { + r0 = rf(userId, teamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.ScheduledPost) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(userId, teamId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PermanentlyDeleteScheduledPosts provides a mock function with given fields: scheduledPostIDs +func (_m *ScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error { + ret := _m.Called(scheduledPostIDs) + + if len(ret) == 0 { + panic("no return value specified for PermanentlyDeleteScheduledPosts") + } + + var r0 error + if rf, ok := ret.Get(0).(func([]string) error); ok { + r0 = rf(scheduledPostIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateOldScheduledPosts provides a mock function with given fields: beforeTime +func (_m *ScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { + ret := _m.Called(beforeTime) + + if len(ret) == 0 { + panic("no return value specified for UpdateOldScheduledPosts") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int64) error); ok { + r0 = rf(beforeTime) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatedScheduledPost provides a mock function with given fields: scheduledPost +func (_m *ScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { + ret := _m.Called(scheduledPost) + + if len(ret) == 0 { + panic("no return value specified for UpdatedScheduledPost") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.ScheduledPost) error); ok { + r0 = rf(scheduledPost) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewScheduledPostStore creates a new instance of ScheduledPostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewScheduledPostStore(t interface { + mock.TestingT + Cleanup(func()) +}) *ScheduledPostStore { + mock := &ScheduledPostStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index e047cca940..a52b352c9c 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -919,6 +919,26 @@ func (_m *Store) Role() store.RoleStore { return r0 } +// ScheduledPost provides a mock function with given fields: +func (_m *Store) ScheduledPost() store.ScheduledPostStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ScheduledPost") + } + + var r0 store.ScheduledPostStore + if rf, ok := ret.Get(0).(func() store.ScheduledPostStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ScheduledPostStore) + } + } + + return r0 +} + // Scheme provides a mock function with given fields: func (_m *Store) Scheme() store.SchemeStore { ret := _m.Called() diff --git a/server/channels/store/storetest/scheduled_post_store.go b/server/channels/store/storetest/scheduled_post_store.go new file mode 100644 index 0000000000..4515344745 --- /dev/null +++ b/server/channels/store/storetest/scheduled_post_store.go @@ -0,0 +1,472 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScheduledPostStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("CreateScheduledPost", func(t *testing.T) { testCreateScheduledPost(t, rctx, ss, s) }) + t.Run("GetPendingScheduledPosts", func(t *testing.T) { testGetScheduledPosts(t, rctx, ss, s) }) + t.Run("PermanentlyDeleteScheduledPosts", func(t *testing.T) { testPermanentlyDeleteScheduledPosts(t, rctx, ss, s) }) + t.Run("UpdatedScheduledPost", func(t *testing.T) { testUpdatedScheduledPost(t, rctx, ss, s) }) + t.Run("UpdateOldScheduledPosts", func(t *testing.T) { testUpdateOldScheduledPosts(t, rctx, ss, s) }) +} + +func testCreateScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + channel := &model.Channel{ + TeamId: "team_id_1", + Type: model.ChannelTypeOpen, + Name: "channel_name", + DisplayName: "Channel Name", + } + + createdChannel, err := ss.Channel().Save(rctx, channel, 1000) + assert.NoError(t, err) + + defer func() { + _ = ss.Channel().PermanentDelete(rctx, createdChannel.Id) + }() + + t.Run("base case", func(t *testing.T) { + userId := model.NewId() + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future + } + + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + + defer func() { + _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{createdScheduledPost.Id}) + }() + + scheduledPostsFromDatabase, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, "team_id_1") + assert.NoError(t, err) + require.Equal(t, 1, len(scheduledPostsFromDatabase)) + assert.Equal(t, scheduledPost.Id, scheduledPostsFromDatabase[0].Id) + }) + + t.Run("scheduling in past SHOULD BE allowed", func(t *testing.T) { + // this is only allowed in store layer and user won't be able to do so as the checks + // in app layer would stop them. + userId := model.NewId() + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() - 100000, // 100 seconds in the past + } + + _, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + + defer func() { + _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPost.Id}) + }() + }) +} + +func testGetScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + t.Run("should handle no scheduled posts exist", func(t *testing.T) { + apr2022 := time.Date(2100, time.April, 1, 1, 0, 0, 0, time.UTC) + afterTime := time.Date(2100, time.March, 1, 1, 0, 0, 0, time.UTC) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) + assert.NoError(t, err) + assert.Equal(t, 0, len(scheduledPosts)) + }) + + t.Run("base case", func(t *testing.T) { + // creating some sample scheduled posts + // Create a time object for 1 January 2100, 1 AM + jan2100 := time.Date(2100, time.January, 1, 1, 0, 0, 0, time.UTC) + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillisForTime(jan2100), + } + + createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost1.Id) + + feb2100 := time.Date(2100, time.February, 1, 1, 0, 0, 0, time.UTC) + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillisForTime(feb2100), + } + + createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost2.Id) + + mar2100 := time.Date(2100, time.March, 1, 1, 0, 0, 0, time.UTC) + scheduledPost3 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillisForTime(mar2100), + } + + createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost3) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost3.Id) + + defer func() { + _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{ + createdScheduledPost1.Id, + createdScheduledPost2.Id, + createdScheduledPost3.Id, + }) + }() + + apr2022 := time.Date(2100, time.April, 1, 1, 0, 0, 0, time.UTC) + afterTime := time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(apr2022), model.GetMillisForTime(afterTime), "", 10) + assert.NoError(t, err) + assert.Equal(t, 3, len(scheduledPosts)) + + mar2100midnight := time.Date(2100, time.March, 1, 0, 0, 0, 0, time.UTC) + afterTime = time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(mar2100midnight), model.GetMillisForTime(afterTime), "", 10) + assert.NoError(t, err) + assert.Equal(t, 2, len(scheduledPosts)) + + jan2100Midnight := time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC) + afterTime = time.Date(2099, time.December, 31, 0, 0, 0, 0, time.UTC) + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillisForTime(jan2100Midnight), model.GetMillisForTime(afterTime), "", 10) + assert.NoError(t, err) + assert.Equal(t, 0, len(scheduledPosts)) + }) +} + +func testPermanentlyDeleteScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + scheduledPostIDs := []string{} + + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, + } + + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) + + scheduledPost = &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post 2", + }, + ScheduledAt: model.GetMillis() + 100000, + } + + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) + + scheduledPost = &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post 3", + }, + ScheduledAt: model.GetMillis() + 100000, + } + + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) + + scheduledPost = &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: model.NewId(), + ChannelId: model.NewId(), + Message: "this is a scheduled post 4", + }, + ScheduledAt: model.GetMillis() + 100000, + } + + createdScheduledPost, err = ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + scheduledPostIDs = append(scheduledPostIDs, createdScheduledPost.Id) + + // verify 4 scheduled posts exist + scheduledPosts, err := ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) + assert.NoError(t, err) + assert.Equal(t, 4, len(scheduledPosts)) + + // now we'll delete all scheduled posts + err = ss.ScheduledPost().PermanentlyDeleteScheduledPosts(scheduledPostIDs) + assert.NoError(t, err) + + // now there should be no posts + scheduledPosts, err = ss.ScheduledPost().GetPendingScheduledPosts(model.GetMillis()+50000000, model.GetMillis()-100000000, "", 10) + assert.NoError(t, err) + assert.Equal(t, 0, len(scheduledPosts)) +} + +func testUpdatedScheduledPost(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + channel := &model.Channel{ + TeamId: "team_id_1", + Type: model.ChannelTypeOpen, + Name: "channel_name", + DisplayName: "Channel Name", + } + + createdChannel, err := ss.Channel().Save(rctx, channel, 1000) + assert.NoError(t, err) + + defer func() { + _ = ss.Channel().PermanentDelete(rctx, createdChannel.Id) + }() + + t.Run("it should update only limited fields", func(t *testing.T) { + userId := model.NewId() + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis(), + } + + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + + // now we'll update the scheduled post + updateTimestamp := model.GetMillis() + + fileID1 := model.NewId() + fileID2 := model.NewId() + + newScheduledAt := model.GetMillis() + newUserId := model.NewId() + + updateSchedulePost := &model.ScheduledPost{ + Id: createdScheduledPost.Id, + ScheduledAt: newScheduledAt, + ErrorCode: "test_error_code", + Draft: model.Draft{ + CreateAt: model.GetMillis(), + Message: "updated message", + UpdateAt: updateTimestamp, + UserId: newUserId, // this should not update + ChannelId: model.NewId(), // this should not update + FileIds: []string{fileID1, fileID2}, + Priority: model.StringInterface{ + "priority": "urgent", + "requested_ack": false, + "persistent_notifications": false, + }, + }, + } + + err = ss.ScheduledPost().UpdatedScheduledPost(updateSchedulePost) + assert.NoError(t, err) + + // now we'll get it and verify that intended fields updated and other fields did not + userScheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, channel.TeamId) + assert.NoError(t, err) + assert.Equal(t, 1, len(userScheduledPosts)) + + // fields that should have changed + assert.Equal(t, newScheduledAt, userScheduledPosts[0].ScheduledAt) + assert.Equal(t, "test_error_code", userScheduledPosts[0].ErrorCode) + assert.Equal(t, "updated message", userScheduledPosts[0].Message) + assert.Equal(t, 2, len(userScheduledPosts[0].FileIds)) + assert.Equal(t, "urgent", userScheduledPosts[0].Priority["priority"]) + assert.Equal(t, false, userScheduledPosts[0].Priority["requested_ack"]) + assert.Equal(t, false, userScheduledPosts[0].Priority["persistent_notifications"]) + + // fields that should not have changed. Checking them against the original value + assert.Equal(t, createdScheduledPost.Id, userScheduledPosts[0].Id) + assert.Equal(t, userId, userScheduledPosts[0].UserId) + assert.Equal(t, channel.Id, userScheduledPosts[0].ChannelId) + }) + + t.Run("it should update old scheduled post", func(t *testing.T) { + userId := model.NewId() + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() - (24 * 60 * 60 * 1000), // 1 day in the past + } + + createdScheduledPost, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost.Id) + + // now we'll update the scheduled post + processedAt := model.GetMillis() + scheduledPost.ProcessedAt = processedAt + scheduledPost.ErrorCode = model.ScheduledPostErrorUnknownError + + err = ss.ScheduledPost().UpdatedScheduledPost(scheduledPost) + assert.NoError(t, err) + + updatedScheduledPost, err := ss.ScheduledPost().Get(scheduledPost.Id) + assert.NoError(t, err) + assert.Equal(t, processedAt, updatedScheduledPost.ProcessedAt) + assert.Equal(t, model.ScheduledPostErrorUnknownError, updatedScheduledPost.ErrorCode) + }) +} + +func testUpdateOldScheduledPosts(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { + setupScheduledPosts := func(baseTime int64, userId, teamId string) func() { + channel := &model.Channel{ + TeamId: teamId, + Type: model.ChannelTypeOpen, + Name: "channel_name", + DisplayName: "Channel Name", + } + createdChannel, err := ss.Channel().Save(rctx, channel, 1000) + assert.NoError(t, err) + + // Scheduled post 1 + scheduledPost1 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: baseTime + 86400000, // 1 day in the future + } + + createdScheduledPost1, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost1) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost1.Id) + + time.Sleep(100 * time.Millisecond) + + // Scheduled post 2 + scheduledPost2 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is second scheduled post", + }, + ScheduledAt: baseTime + (2 * 86400000), // 2 days in the future + } + + createdScheduledPost2, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost2) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost2.Id) + + time.Sleep(100 * time.Millisecond) + + // Scheduled post 3 + scheduledPost3 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is third scheduled post", + }, + ScheduledAt: baseTime + (3 * 86400000), // 3 days in the future + } + + createdScheduledPost3, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost3) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost3.Id) + + time.Sleep(100 * time.Millisecond) + + // Scheduled post 4 + scheduledPost4 := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: userId, + ChannelId: createdChannel.Id, + Message: "this is fourth scheduled post", + }, + ScheduledAt: baseTime + (4 * 86400000), // 4 days in the future + } + + createdScheduledPost4, err := ss.ScheduledPost().CreateScheduledPost(scheduledPost4) + assert.NoError(t, err) + assert.NotEmpty(t, createdScheduledPost4.Id) + + return func() { + _ = ss.ScheduledPost().PermanentlyDeleteScheduledPosts([]string{ + createdScheduledPost1.Id, + createdScheduledPost2.Id, + createdScheduledPost3.Id, + createdScheduledPost4.Id, + }) + + _ = ss.Channel().PermanentDelete(rctx, createdChannel.Id) + } + } + + t.Run("should update only old scheduled posts", func(t *testing.T) { + now := model.GetMillis() + userId := model.NewId() + teamId := model.NewId() + cleanup := setupScheduledPosts(now, userId, teamId) + defer cleanup() + + err := ss.ScheduledPost().UpdateOldScheduledPosts(now + 2.5*86400000) // marking all posts older than 2 days from now + assert.NoError(t, err) + + scheduledPosts, err := ss.ScheduledPost().GetScheduledPostsForUser(userId, teamId) + assert.NoError(t, err) + assert.Equal(t, 4, len(scheduledPosts)) + assert.Equal(t, model.ScheduledPostErrorUnableToSend, scheduledPosts[0].ErrorCode) + assert.Equal(t, model.ScheduledPostErrorUnableToSend, scheduledPosts[1].ErrorCode) + assert.Equal(t, "", scheduledPosts[2].ErrorCode) + assert.Equal(t, "", scheduledPosts[3].ErrorCode) + }) +} diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index dfe1d4c416..812b5601ac 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -65,6 +65,7 @@ type Store struct { PostPersistentNotificationStore mocks.PostPersistentNotificationStore DesktopTokensStore mocks.DesktopTokensStore ChannelBookmarkStore mocks.ChannelBookmarkStore + ScheduledPostStore mocks.ScheduledPostStore } func (s *Store) SetContext(context context.Context) { s.context = context } @@ -117,6 +118,7 @@ func (s *Store) Group() store.GroupStore { return &s.GroupSt func (s *Store) LinkMetadata() store.LinkMetadataStore { return &s.LinkMetadataStore } func (s *Store) SharedChannel() store.SharedChannelStore { return &s.SharedChannelStore } func (s *Store) PostPriority() store.PostPriorityStore { return &s.PostPriorityStore } +func (s *Store) ScheduledPost() store.ScheduledPostStore { return &s.ScheduledPostStore } func (s *Store) PostAcknowledgement() store.PostAcknowledgementStore { return &s.PostAcknowledgementStore } @@ -188,5 +190,6 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.PostPersistentNotificationStore, &s.DesktopTokensStore, &s.ChannelBookmarkStore, + &s.ScheduledPostStore, ) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index ae65b7b1f2..acfbf4f023 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -50,6 +50,7 @@ type TimerLayer struct { RemoteClusterStore store.RemoteClusterStore RetentionPolicyStore store.RetentionPolicyStore RoleStore store.RoleStore + ScheduledPostStore store.ScheduledPostStore SchemeStore store.SchemeStore SessionStore store.SessionStore SharedChannelStore store.SharedChannelStore @@ -190,6 +191,10 @@ func (s *TimerLayer) Role() store.RoleStore { return s.RoleStore } +func (s *TimerLayer) ScheduledPost() store.ScheduledPostStore { + return s.ScheduledPostStore +} + func (s *TimerLayer) Scheme() store.SchemeStore { return s.SchemeStore } @@ -401,6 +406,11 @@ type TimerLayerRoleStore struct { Root *TimerLayer } +type TimerLayerScheduledPostStore struct { + store.ScheduledPostStore + Root *TimerLayer +} + type TimerLayerSchemeStore struct { store.SchemeStore Root *TimerLayer @@ -7753,6 +7763,134 @@ func (s *TimerLayerRoleStore) Save(role *model.Role) (*model.Role, error) { return result, err } +func (s *TimerLayerScheduledPostStore) CreateScheduledPost(scheduledPost *model.ScheduledPost) (*model.ScheduledPost, error) { + start := time.Now() + + result, err := s.ScheduledPostStore.CreateScheduledPost(scheduledPost) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.CreateScheduledPost", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledPostStore) Get(scheduledPostId string) (*model.ScheduledPost, error) { + start := time.Now() + + result, err := s.ScheduledPostStore.Get(scheduledPostId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledPostStore) GetMaxMessageSize() int { + start := time.Now() + + result := s.ScheduledPostStore.GetMaxMessageSize() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if true { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.GetMaxMessageSize", success, elapsed) + } + return result +} + +func (s *TimerLayerScheduledPostStore) GetPendingScheduledPosts(beforeTime int64, afterTime int64, lastScheduledPostId string, perPage uint64) ([]*model.ScheduledPost, error) { + start := time.Now() + + result, err := s.ScheduledPostStore.GetPendingScheduledPosts(beforeTime, afterTime, lastScheduledPostId, perPage) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.GetPendingScheduledPosts", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledPostStore) GetScheduledPostsForUser(userId string, teamId string) ([]*model.ScheduledPost, error) { + start := time.Now() + + result, err := s.ScheduledPostStore.GetScheduledPostsForUser(userId, teamId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.GetScheduledPostsForUser", success, elapsed) + } + return result, err +} + +func (s *TimerLayerScheduledPostStore) PermanentlyDeleteScheduledPosts(scheduledPostIDs []string) error { + start := time.Now() + + err := s.ScheduledPostStore.PermanentlyDeleteScheduledPosts(scheduledPostIDs) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.PermanentlyDeleteScheduledPosts", success, elapsed) + } + return err +} + +func (s *TimerLayerScheduledPostStore) UpdateOldScheduledPosts(beforeTime int64) error { + start := time.Now() + + err := s.ScheduledPostStore.UpdateOldScheduledPosts(beforeTime) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.UpdateOldScheduledPosts", success, elapsed) + } + return err +} + +func (s *TimerLayerScheduledPostStore) UpdatedScheduledPost(scheduledPost *model.ScheduledPost) error { + start := time.Now() + + err := s.ScheduledPostStore.UpdatedScheduledPost(scheduledPost) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ScheduledPostStore.UpdatedScheduledPost", success, elapsed) + } + return err +} + func (s *TimerLayerSchemeStore) CountByScope(scope string) (int64, error) { start := time.Now() @@ -12313,6 +12451,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.RemoteClusterStore = &TimerLayerRemoteClusterStore{RemoteClusterStore: childStore.RemoteCluster(), Root: &newStore} newStore.RetentionPolicyStore = &TimerLayerRetentionPolicyStore{RetentionPolicyStore: childStore.RetentionPolicy(), Root: &newStore} newStore.RoleStore = &TimerLayerRoleStore{RoleStore: childStore.Role(), Root: &newStore} + newStore.ScheduledPostStore = &TimerLayerScheduledPostStore{ScheduledPostStore: childStore.ScheduledPost(), Root: &newStore} newStore.SchemeStore = &TimerLayerSchemeStore{SchemeStore: childStore.Scheme(), Root: &newStore} newStore.SessionStore = &TimerLayerSessionStore{SessionStore: childStore.Session(), Root: &newStore} newStore.SharedChannelStore = &TimerLayerSharedChannelStore{SharedChannelStore: childStore.SharedChannel(), Root: &newStore} diff --git a/server/config/client.go b/server/config/client.go index 59366327c3..3f2dd4545a 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -222,10 +222,8 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise { props["EnableCustomGroups"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomGroups) - } - - if license.SkuShortName == model.LicenseShortSkuProfessional || license.SkuShortName == model.LicenseShortSkuEnterprise { props["PostAcknowledgements"] = "true" + props["ScheduledPosts"] = strconv.FormatBool(*c.ServiceSettings.ScheduledPosts) } } diff --git a/server/i18n/en.json b/server/i18n/en.json index 193c47cded..3f0096496b 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2838,6 +2838,14 @@ "id": "api.saml.invalid_email_token.app_error", "translation": "Invalid email_token" }, + { + "id": "api.scheduled_posts.feature_disabled", + "translation": "scheduled posts feature is disabled" + }, + { + "id": "api.scheduled_posts.license_error", + "translation": "Scheduled posts feature requires a license" + }, { "id": "api.scheme.create_scheme.license.error", "translation": "Your license does not support creating permissions schemes." @@ -4958,6 +4966,22 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.delete_scheduled_post.delete_error", + "translation": "Failed to delete scheduled post from database." + }, + { + "id": "app.delete_scheduled_post.delete_permission.error", + "translation": "You do not have permission to delete this resource." + }, + { + "id": "app.delete_scheduled_post.existing_scheduled_post.not_exist", + "translation": "Scheduled post does not exist." + }, + { + "id": "app.delete_scheduled_post.get_scheduled_post.error", + "translation": "Unable to fetch existing scheduled post from database." + }, { "id": "app.desktop_token.generateServerToken.invalid_or_expired", "translation": "Token does not exist or is expired" @@ -5138,6 +5162,10 @@ "id": "app.file_info.set_searchable_content.app_error", "translation": "Unable to set the searchable content of the file." }, + { + "id": "app.get_user_team_scheduled_posts.error", + "translation": "Error occurred fetching scheduled posts." + }, { "id": "app.group.crud_permission", "translation": "Unable to perform operation for that source type." @@ -6482,6 +6510,14 @@ "id": "app.save_report_chunk.unsupported_format", "translation": "Unsupported report format." }, + { + "id": "app.save_scheduled_post.channel_deleted.app_error", + "translation": "Cannot schedule post in an archived channel." + }, + { + "id": "app.save_scheduled_post.save.app_error", + "translation": "Error occurred saving the scheduled post." + }, { "id": "app.scheme.delete.app_error", "translation": "Unable to delete this scheme." @@ -6806,6 +6842,22 @@ "id": "app.update_error", "translation": "update error" }, + { + "id": "app.update_scheduled_post.existing_scheduled_post.not_exist", + "translation": "Scheduled post does not exist." + }, + { + "id": "app.update_scheduled_post.get_scheduled_post.error", + "translation": "Unable to fetch existing scheduled post from database." + }, + { + "id": "app.update_scheduled_post.update.error", + "translation": "Failed to save updated scheduled post in database." + }, + { + "id": "app.update_scheduled_post.update_permission.error", + "translation": "You do not have permission to update this resource." + }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", "translation": "Cannot upload to a deleted channel." @@ -9662,6 +9714,22 @@ "id": "model.reporting_base_options.is_valid.bad_date_range", "translation": "Date range provided is invalid." }, + { + "id": "model.scheduled_post.is_valid.empty_post.app_error", + "translation": "Cannot schedule an empty post. Scheduled post must have at least a message or file attachments." + }, + { + "id": "model.scheduled_post.is_valid.id.app_error", + "translation": "Scheduled post must have an ID." + }, + { + "id": "model.scheduled_post.is_valid.processed_at.app_error", + "translation": "Invalid processed at time." + }, + { + "id": "model.scheduled_post.is_valid.scheduled_at.app_error", + "translation": "Invalid scheduled at time." + }, { "id": "model.scheme.is_valid.app_error", "translation": "Invalid scheme." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index bffa1b6258..9b37815184 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -612,6 +612,73 @@ func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response return &serverLimits, BuildResponse(r), nil } +func (c *Client4) CreateScheduledPost(ctx context.Context, scheduledPost *ScheduledPost) (*ScheduledPost, *Response, error) { + buf, err := json.Marshal(scheduledPost) + if err != nil { + return nil, nil, NewAppError("CreateScheduledPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPost(ctx, c.postsRoute()+"/schedule", string(buf)) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var createdScheduledPost ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&createdScheduledPost); err != nil { + return nil, nil, NewAppError("CreateScheduledPost", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &createdScheduledPost, BuildResponse(r), nil +} + +func (c *Client4) GetUserScheduledPosts(ctx context.Context, teamId string, includeDirectChannels bool) (map[string][]*ScheduledPost, *Response, error) { + query := url.Values{} + query.Set("includeDirectChannels", fmt.Sprintf("%t", includeDirectChannels)) + + r, err := c.DoAPIGet(ctx, c.postsRoute()+"/scheduled/team/"+teamId+"?"+query.Encode(), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var scheduledPostsByTeam map[string][]*ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&scheduledPostsByTeam); err != nil { + return nil, nil, NewAppError("GetUserScheduledPosts", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return scheduledPostsByTeam, BuildResponse(r), nil +} + +func (c *Client4) UpdateScheduledPost(ctx context.Context, scheduledPost *ScheduledPost) (*ScheduledPost, *Response, error) { + buf, err := json.Marshal(scheduledPost) + if err != nil { + return nil, nil, NewAppError("UpdateScheduledPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPut(ctx, c.postsRoute()+"/schedule/"+scheduledPost.Id, string(buf)) + if err != nil { + return nil, BuildResponse(r), err + } + + defer closeBody(r) + var updatedScheduledPost ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&updatedScheduledPost); err != nil { + return nil, nil, NewAppError("UpdateScheduledPost", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &updatedScheduledPost, BuildResponse(r), nil +} + +func (c *Client4) DeleteScheduledPost(ctx context.Context, scheduledPostId string) (*ScheduledPost, *Response, error) { + r, err := c.DoAPIDelete(ctx, c.postsRoute()+"/schedule/"+scheduledPostId) + if err != nil { + return nil, BuildResponse(r), err + } + + defer closeBody(r) + var deletedScheduledPost ScheduledPost + if err := json.NewDecoder(r.Body).Decode(&deletedScheduledPost); err != nil { + return nil, nil, NewAppError("DeleteScheduledPost", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &deletedScheduledPost, BuildResponse(r), nil +} + func (c *Client4) bookmarksRoute(channelId string) string { return c.channelRoute(channelId) + "/bookmarks" } diff --git a/server/public/model/config.go b/server/public/model/config.go index ef0d3de51d..d5a905d6e4 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -419,6 +419,7 @@ type ServiceSettings struct { RefreshPostStatsRunTime *string `access:"site_users_and_teams"` MaximumPayloadSizeBytes *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` MaximumURLLength *int `access:"environment_file_storage,write_restrictable,cloud_restrictable"` + ScheduledPosts *bool `access:"site_posts"` } var MattermostGiphySdkKey string @@ -942,6 +943,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.MaximumURLLength == nil { s.MaximumURLLength = NewPointer(ServiceSettingsDefaultMaxURLLength) } + + if s.ScheduledPosts == nil { + s.ScheduledPosts = NewPointer(true) + } } type CacheSettings struct { diff --git a/server/public/model/draft.go b/server/public/model/draft.go index e4b6c6c319..be07894f10 100644 --- a/server/public/model/draft.go +++ b/server/public/model/draft.go @@ -27,6 +27,14 @@ type Draft struct { } func (o *Draft) IsValid(maxDraftSize int) *AppError { + if utf8.RuneCountInString(o.Message) > maxDraftSize { + return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) + } + + return o.BaseIsValid() +} + +func (o *Draft) BaseIsValid() *AppError { if o.CreateAt == 0 { return NewAppError("Drafts.IsValid", "model.draft.is_valid.create_at.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) } @@ -47,10 +55,6 @@ func (o *Draft) IsValid(maxDraftSize int) *AppError { return NewAppError("Drafts.IsValid", "model.draft.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) } - if utf8.RuneCountInString(o.Message) > maxDraftSize { - return NewAppError("Drafts.IsValid", "model.draft.is_valid.msg.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) - } - if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes { return NewAppError("Drafts.IsValid", "model.draft.is_valid.file_ids.app_error", nil, "channelid="+o.ChannelId, http.StatusBadRequest) } diff --git a/server/public/model/license.go b/server/public/model/license.go index bc2615371d..0ca37bde25 100644 --- a/server/public/model/license.go +++ b/server/public/model/license.go @@ -458,3 +458,10 @@ func (lr *LicenseRecord) IsValid() *AppError { func (lr *LicenseRecord) PreSave() { lr.CreateAt = GetMillis() } + +func MinimumProfessionalProvidedLicense(license *License) *AppError { + if license == nil || (license.SkuShortName != LicenseShortSkuProfessional && license.SkuShortName != LicenseShortSkuEnterprise) { + return NewAppError("", NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) + } + return nil +} diff --git a/server/public/model/permission.go b/server/public/model/permission.go index a6864f1186..1525d25642 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -2604,6 +2604,10 @@ func init() { } func MakePermissionError(s *Session, permissions []*Permission) *AppError { + return MakePermissionErrorForUser(s.UserId, permissions) +} + +func MakePermissionErrorForUser(userId string, permissions []*Permission) *AppError { permissionsStr := "permission=" for i, permission := range permissions { permissionsStr += permission.Id @@ -2611,5 +2615,5 @@ func MakePermissionError(s *Session, permissions []*Permission) *AppError { permissionsStr += "," } } - return NewAppError("Permissions", "api.context.permissions.app_error", nil, "userId="+s.UserId+", "+permissionsStr, http.StatusForbidden) + return NewAppError("Permissions", "api.context.permissions.app_error", nil, "userId="+userId+", "+permissionsStr, http.StatusForbidden) } diff --git a/server/public/model/post.go b/server/public/model/post.go index 0ea2d7e13b..7063d22ea0 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -523,17 +523,17 @@ func (o *Post) SanitizeInput() { } func (o *Post) ContainsIntegrationsReservedProps() []string { - return containsIntegrationsReservedProps(o.GetProps()) + return ContainsIntegrationsReservedProps(o.GetProps()) } func (o *PostPatch) ContainsIntegrationsReservedProps() []string { if o == nil || o.Props == nil { return nil } - return containsIntegrationsReservedProps(*o.Props) + return ContainsIntegrationsReservedProps(*o.Props) } -func containsIntegrationsReservedProps(props StringInterface) []string { +func ContainsIntegrationsReservedProps(props StringInterface) []string { foundProps := []string{} if props != nil { diff --git a/server/public/model/scheduled_post.go b/server/public/model/scheduled_post.go new file mode 100644 index 0000000000..fadf5992ea --- /dev/null +++ b/server/public/model/scheduled_post.go @@ -0,0 +1,171 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "fmt" + "net/http" +) + +const ( + ScheduledPostErrorUnknownError = "unknown" + ScheduledPostErrorCodeChannelArchived = "channel_archived" + ScheduledPostErrorCodeChannelNotFound = "channel_not_found" + ScheduledPostErrorCodeUserDoesNotExist = "user_missing" + ScheduledPostErrorCodeUserDeleted = "user_deleted" + ScheduledPostErrorCodeNoChannelPermission = "no_channel_permission" + ScheduledPostErrorNoChannelMember = "no_channel_member" + ScheduledPostErrorThreadDeleted = "thread_deleted" + ScheduledPostErrorUnableToSend = "unable_to_send" + ScheduledPostErrorInvalidPost = "invalid_post" +) + +// allow scheduled posts to be created up to +// this much time in the past. While this ir primarily added for reliable test cases, +// it also helps with flaky and slow network connection between the client and the server, +const scheduledPostMaxTimeGap = -5000 + +type ScheduledPost struct { + Draft + Id string `json:"id"` + ScheduledAt int64 `json:"scheduled_at"` + ProcessedAt int64 `json:"processed_at"` + ErrorCode string `json:"error_code"` +} + +func (s *ScheduledPost) IsValid(maxMessageSize int) *AppError { + draftAppErr := s.Draft.IsValid(maxMessageSize) + if draftAppErr != nil { + return draftAppErr + } + + return s.BaseIsValid() +} + +func (s *ScheduledPost) BaseIsValid() *AppError { + if draftAppErr := s.Draft.BaseIsValid(); draftAppErr != nil { + return draftAppErr + } + + if s.Id == "" { + return NewAppError("ScheduledPost.IsValid", "model.scheduled_post.is_valid.id.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + + if len(s.Message) == 0 && len(s.FileIds) == 0 { + return NewAppError("ScheduledPost.IsValid", "model.scheduled_post.is_valid.empty_post.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + + if (s.ScheduledAt - GetMillis()) < scheduledPostMaxTimeGap { + return NewAppError("ScheduledPost.IsValid", "model.scheduled_post.is_valid.scheduled_at.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + + if s.ProcessedAt < 0 { + return NewAppError("ScheduledPost.IsValid", "model.scheduled_post.is_valid.processed_at.app_error", nil, "id="+s.Id, http.StatusBadRequest) + } + + return nil +} + +func (s *ScheduledPost) PreSave() { + if s.Id == "" { + s.Id = NewId() + } + + s.ProcessedAt = 0 + s.ErrorCode = "" + + s.Draft.PreSave() +} + +func (s *ScheduledPost) PreUpdate() { + s.Draft.UpdateAt = GetMillis() + s.Draft.PreCommit() +} + +// ToPost converts a scheduled post toa regular, mattermost post object. +func (s *ScheduledPost) ToPost() (*Post, error) { + post := &Post{ + UserId: s.UserId, + ChannelId: s.ChannelId, + Message: s.Message, + FileIds: s.FileIds, + RootId: s.RootId, + Metadata: s.Metadata, + } + + for key, value := range s.GetProps() { + post.AddProp(key, value) + } + + if len(s.Priority) > 0 { + priority, ok := s.Priority["priority"].(string) + if !ok { + return nil, fmt.Errorf(`ScheduledPost.ToPost: priority is not a string. ScheduledPost.Priority: %v`, s.Priority) + } + + requestedAck, ok := s.Priority["requested_ack"].(bool) + if !ok { + return nil, fmt.Errorf(`ScheduledPost.ToPost: requested_ack is not a bool. ScheduledPost.Priority: %v`, s.Priority) + } + + persistentNotifications, ok := s.Priority["persistent_notifications"].(bool) + if !ok { + return nil, fmt.Errorf(`ScheduledPost.ToPost: persistent_notifications is not a bool. ScheduledPost.Priority: %v`, s.Priority) + } + + if post.Metadata == nil { + post.Metadata = &PostMetadata{} + } + + post.Metadata.Priority = &PostPriority{ + Priority: NewPointer(priority), + RequestedAck: NewPointer(requestedAck), + PersistentNotifications: NewPointer(persistentNotifications), + } + } + + return post, nil +} + +func (s *ScheduledPost) Auditable() map[string]interface{} { + var metaData map[string]any + if s.Metadata != nil { + metaData = s.Metadata.Auditable() + } + + return map[string]interface{}{ + "id": s.Id, + "create_at": s.CreateAt, + "update_at": s.UpdateAt, + "user_id": s.UserId, + "channel_id": s.ChannelId, + "root_id": s.RootId, + "props": s.GetProps(), + "file_ids": s.FileIds, + "metadata": metaData, + } +} + +func (s *ScheduledPost) RestoreNonUpdatableFields(originalScheduledPost *ScheduledPost) { + s.Id = originalScheduledPost.Id + s.CreateAt = originalScheduledPost.CreateAt + s.UserId = originalScheduledPost.UserId + s.ChannelId = originalScheduledPost.ChannelId + s.RootId = originalScheduledPost.RootId +} + +func (s *ScheduledPost) SanitizeInput() { + s.CreateAt = 0 + + if s.Metadata != nil { + s.Metadata.Embeds = nil + } +} + +func (s *ScheduledPost) GetPriority() *PostPriority { + if s.Metadata == nil { + return nil + } + return s.Metadata.Priority +} diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index 82c68c4ddd..45165c64f0 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -16,6 +16,7 @@ import { getChannelStats, selectChannel, } from 'mattermost-redux/actions/channels'; +import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts'; import {logout, loadMe} from 'mattermost-redux/actions/users'; import {Preferences} from 'mattermost-redux/constants'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; @@ -383,6 +384,7 @@ export async function redirectUserToDefaultTeam(searchParams?: URLSearchParams) if (team && team.delete_at === 0) { const channel = await getTeamRedirectChannelIfIsAccesible(user, team); if (channel) { + dispatch(fetchTeamScheduledPosts(team.id, true)); dispatch(selectChannel(channel.id)); historyPushWithQueryParams(`/${team.name}/channels/${channel.name}`, searchParams); return; diff --git a/webapp/channels/src/actions/post_actions.ts b/webapp/channels/src/actions/post_actions.ts index 53e5ce9a3e..6264b09b52 100644 --- a/webapp/channels/src/actions/post_actions.ts +++ b/webapp/channels/src/actions/post_actions.ts @@ -4,10 +4,12 @@ import type {FileInfo} from '@mattermost/types/files'; import type {GroupChannel} from '@mattermost/types/groups'; import type {Post} from '@mattermost/types/posts'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; import {SearchTypes} from 'mattermost-redux/action_types'; import {getMyChannelMember} from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; +import {createSchedulePost} from 'mattermost-redux/actions/scheduled_posts'; import * as ThreadActions from 'mattermost-redux/actions/threads'; import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; @@ -15,7 +17,13 @@ import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; -import type {DispatchFunc, ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions'; +import type { + DispatchFunc, + ActionFunc, + ActionFuncAsync, + ThunkActionFunc, + GetStateFunc, +} from 'mattermost-redux/types/actions'; import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils'; import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions'; @@ -25,6 +33,7 @@ import {removeDraft} from 'actions/views/drafts'; import {closeModal, openModal} from 'actions/views/modals'; import * as RhsActions from 'actions/views/rhs'; import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; +import {getConnectionId} from 'selectors/general'; import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts'; import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs'; import {getGlobalItem} from 'selectors/storage'; @@ -43,9 +52,13 @@ import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForP import type {GlobalState} from 'types/store'; -import {completePostReceive} from './new_post'; import type {NewPostMessageProps} from './new_post'; -import type {SubmitPostReturnType} from './views/create_comment'; +import {completePostReceive} from './new_post'; +import type {OnSubmitOptions, SubmitPostReturnType} from './views/create_comment'; + +export type CreatePostOptions = { + keepDraft?: boolean; +} export function handleNewPost(post: Post, msg?: {data?: NewPostMessageProps & GroupChannel}): ActionFuncAsync { return async (dispatch, getState) => { @@ -106,33 +119,58 @@ export function unflagPost(postId: string): ActionFuncAsync { }; } -export function createPost( - post: Post, - files: FileInfo[], - afterSubmit?: (response: SubmitPostReturnType) => void, - afterOptimisticSubmit?: () => void, -): ActionFuncAsync { - return async (dispatch) => { +function addRecentEmojisForMessage(message: string): ActionFunc { + return (dispatch) => { // parse message and emit emoji event - const emojis = matchEmoticons(post.message); + const emojis = matchEmoticons(message); if (emojis) { const trimmedEmojis = emojis.map((emoji) => emoji.substring(1, emoji.length - 1)); dispatch(addRecentEmojis(trimmedEmojis)); } + return {data: true}; + }; +} + +export function createPost( + post: Post, + files: FileInfo[], + afterSubmit?: (response: SubmitPostReturnType) => void, + options?: OnSubmitOptions, +): ActionFuncAsync { + return async (dispatch) => { + dispatch(addRecentEmojisForMessage(post.message)); const result = await dispatch(PostActions.createPost(post, files, afterSubmit)); - if (post.root_id) { - dispatch(storeCommentDraft(post.root_id, null)); - } else { - dispatch(storeDraft(post.channel_id, null)); + if (!options?.keepDraft) { + if (post.root_id) { + dispatch(storeCommentDraft(post.root_id, null)); + } else { + dispatch(storeDraft(post.channel_id, null)); + } } - afterOptimisticSubmit?.(); + options?.afterOptimisticSubmit?.(); return result; }; } +export function createSchedulePostFromDraft(scheduledPost: ScheduledPost): ActionFuncAsync { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + dispatch(addRecentEmojisForMessage(scheduledPost.message)); + + const state = getState() as GlobalState; + const connectionId = getConnectionId(state); + const channel = state.entities.channels.channels[scheduledPost.channel_id]; + const result = await dispatch(createSchedulePost(scheduledPost, channel.team_id, connectionId)); + + return { + created: !result.error && result.data, + error: result.error, + }; + }; +} + function storeDraft(channelId: string, draft: null): ActionFunc { return (dispatch) => { dispatch(StorageActions.setGlobalItem('draft_' + channelId, draft)); diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index 1739030c58..44a9497d7c 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -2,7 +2,9 @@ // See LICENSE.txt for license information. import type {CommandArgs} from '@mattermost/types/integrations'; -import type {Post} from '@mattermost/types/posts'; +import type {Post, PostMetadata} from '@mattermost/types/posts'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; +import {scheduledPostFromPost} from '@mattermost/types/schedule_post'; import type {CreatePostReturnType, SubmitReactionReturnType} from 'mattermost-redux/actions/posts'; import {addMessageIntoHistory} from 'mattermost-redux/actions/posts'; @@ -25,6 +27,7 @@ import type {ExecuteCommandReturnType} from 'actions/command'; import {executeCommand} from 'actions/command'; import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks'; import * as PostActions from 'actions/post_actions'; +import {createSchedulePostFromDraft} from 'actions/post_actions'; import EmojiMap from 'utils/emoji_map'; import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils'; @@ -38,7 +41,8 @@ export function submitPost( rootId: string, draft: PostDraft, afterSubmit?: (response: SubmitPostReturnType) => void, - afterOptimisticSubmit?: () => void, + schedulingInfo?: SchedulingInfo, + options?: OnSubmitOptions, ): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); @@ -86,7 +90,29 @@ export function submitPost( post = hookResult.data!; - return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, afterOptimisticSubmit)); + if (schedulingInfo) { + const scheduledPost = scheduledPostFromPost(post, schedulingInfo); + scheduledPost.file_ids = draft.fileInfos.map((fileInfo) => fileInfo.id); + if (draft.fileInfos?.length > 0) { + if (!scheduledPost.metadata) { + scheduledPost.metadata = {} as PostMetadata; + } + + scheduledPost.metadata.files = draft.fileInfos; + } + const response = await dispatch(createSchedulePostFromDraft(scheduledPost)); + if (afterSubmit) { + const result: CreatePostReturnType = { + error: response.error, + created: !response.error, + }; + afterSubmit(result); + } + + return response; + } + + return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, options)); }; } @@ -134,16 +160,17 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf } export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType; - export type OnSubmitOptions = { ignoreSlash?: boolean; afterSubmit?: (response: SubmitPostReturnType) => void; afterOptimisticSubmit?: () => void; + keepDraft?: boolean; } export function onSubmit( draft: PostDraft, options: OnSubmitOptions, + schedulingInfo?: SchedulingInfo, ): ActionFuncAsync { return async (dispatch, getState) => { const {message, channelId, rootId} = draft; @@ -151,24 +178,26 @@ export function onSubmit( dispatch(addMessageIntoHistory(message)); - const isReaction = Utils.REACTION_PATTERN.exec(message); + if (!schedulingInfo && !options.ignoreSlash) { + const isReaction = Utils.REACTION_PATTERN.exec(message); - const emojis = getCustomEmojisByName(state); - const emojiMap = new EmojiMap(emojis); + const emojis = getCustomEmojisByName(state); + const emojiMap = new EmojiMap(emojis); - if (isReaction && emojiMap.has(isReaction[2]) && !options.ignoreSlash) { - const latestPostId = getLatestInteractablePostId(state, channelId, rootId); - if (latestPostId) { - return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); + if (isReaction && emojiMap.has(isReaction[2])) { + const latestPostId = getLatestInteractablePostId(state, channelId, rootId); + if (latestPostId) { + return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); + } + return {error: new Error('no post to react to')}; + } + + if (message.indexOf('/') === 0 && !options.ignoreSlash) { + return dispatch(submitCommand(channelId, rootId, draft)); } - return {error: new Error('No post to react to')}; } - if (message.indexOf('/') === 0 && !options.ignoreSlash) { - return dispatch(submitCommand(channelId, rootId, draft)); - } - - return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, options.afterOptimisticSubmit)); + return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, schedulingInfo, options)); }; } diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index d5f6ff69ea..a9e5b90d1d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -2611,6 +2611,15 @@ const AdminDefinition: AdminDefinitionType = { isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), isHidden: it.configIsFalse('ServiceSettings', 'PostPriority'), }, + { + type: 'bool', + key: 'ServiceSettings.ScheduledPosts', + label: defineMessage({id: 'admin.posts.scheduledPosts.title', defaultMessage: 'Scheduled Posts'}), + help_text: defineMessage({id: 'admin.posts.scheduledPosts.description', defaultMessage: 'When enabled, users can schedule and send messages in the future.'}), + help_text_markdown: false, + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), + isHidden: it.not(it.licensed), + }, { type: 'number', key: 'ServiceSettings.PersistentNotificationMaxRecipients', diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx index 15675a9dee..9ea50aa1c5 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -93,7 +93,14 @@ const initialState = { users: { currentUserId: 'current_user_id', profiles: { - current_user_id: TestHelper.getUserMock({id: 'current_user_id', roles: 'user_roles'}), + current_user_id: TestHelper.getUserMock({ + id: 'current_user_id', + roles: 'user_roles', + timezone: { + useAutomaticTimezone: 'true', + automaticTimezone: 'America/New_York', + manualTimezone: '', + }}), }, statuses: { current_user_id: 'online', diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index 24f01665a9..a06d970de8 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -7,6 +7,7 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import type {ServerError} from '@mattermost/types/errors'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {Permissions} from 'mattermost-redux/constants'; @@ -24,6 +25,7 @@ import {makeGetDraft} from 'selectors/rhs'; import {connectionErrorCount} from 'selectors/views/system'; import LocalStorageStore from 'stores/local_storage_store'; +import PostBoxIndicator from 'components/advanced_text_editor/post_box_indicator/post_box_indicator'; import {makeAsyncComponent} from 'components/async_load'; import AutoHeightSwitcher from 'components/common/auto_height_switcher'; import useDidUpdate from 'components/common/hooks/useDidUpdate'; @@ -50,7 +52,6 @@ import type {PostDraft} from 'types/store/draft'; import DoNotDisturbWarning from './do_not_disturb_warning'; import FormattingBar from './formatting_bar'; import {FormattingBarSpacer, Separator} from './formatting_bar/formatting_bar'; -import RemoteUserHour from './remote_user_hour'; import SendButton from './send_button'; import ShowFormat from './show_formatting'; import TexteditorActions from './texteditor_actions'; @@ -121,7 +122,6 @@ const AdvancedTextEditor = ({ const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || ''); const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : '')); const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false)); - const showRemoteUserHour = useSelector((state: GlobalState) => !showDndWarning && Boolean(getDirectChannel(state, channelId)?.teammate_id)); const canPost = useSelector((state: GlobalState) => { const channel = getChannel(state, channelId); @@ -438,6 +438,8 @@ const AdvancedTextEditor = ({ draftRef.current = draft; }, [draft]); + const handleSubmitPostAndScheduledMessage = useCallback((schedulingInfo?: SchedulingInfo) => handleSubmit(undefined, schedulingInfo), [handleSubmit]); + // Set the draft from store when changing post or channels, and store the previous one useEffect(() => { // Store the draft that existed when we opened the channel to know if it should be saved @@ -456,7 +458,8 @@ const AdvancedTextEditor = ({ const sendButton = readOnlyChannel ? null : ( ); @@ -576,12 +579,12 @@ const AdvancedTextEditor = ({ )} {showDndWarning && } - {showRemoteUserHour && ( - - )} +
getDirectChannel(state, channelId)?.teammate_id || ''); + const isTeammateDND = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false)); + const isDM = useSelector((state: GlobalState) => Boolean(getDirectChannel(state, channelId)?.teammate_id)); + const showDndWarning = isTeammateDND && isDM; + + const [timestamp, setTimestamp] = useState(0); + const [showIt, setShowIt] = useState(false); + + const teammateTimezone = useSelector((state: GlobalState) => { + const teammate = teammateId ? getUser(state, teammateId) : undefined; + return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE; + }, (a, b) => a.automaticTimezone === b.automaticTimezone && + a.manualTimezone === b.manualTimezone && + a.useAutomaticTimezone === b.useAutomaticTimezone); + + useEffect(() => { + const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone); + setTimestamp(teammateUserDate.toMillis()); + setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY); + + const interval = setInterval(() => { + const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone); + setTimestamp(teammateUserDate.toMillis()); + setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY); + }, 1000 * 60); + return () => clearInterval(interval); + }, [teammateTimezone.useAutomaticTimezone, teammateTimezone.automaticTimezone, teammateTimezone.manualTimezone]); + + const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); + + const showRemoteUserHour = showDndWarning && showIt && timestamp !== 0; + + return ( +
+ { + showRemoteUserHour && + + } + + { + + isScheduledPostEnabled && + + } +
+ ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/post_box_indicator/style.scss b/webapp/channels/src/components/advanced_text_editor/post_box_indicator/style.scss new file mode 100644 index 0000000000..d33be14d93 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/post_box_indicator/style.scss @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.postBoxIndicator { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + padding-inline: 24px; + + &:has(div) { + padding-block: 8px; + } + + div { + padding: 0; + } + + span, a { + text-wrap: nowrap; + } + + .userDisplayName { + position: relative; + top: 4px; + display: inline-block; + overflow: hidden; + max-width: 350px; + text-overflow: ellipsis; + text-wrap: nowrap; + } +} diff --git a/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx b/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx index 3dba15fe6b..044a69c9f2 100644 --- a/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx +++ b/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx @@ -1,22 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {DateTime} from 'luxon'; -import React, {useState, useEffect} from 'react'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; import styled from 'styled-components'; -import type {GlobalState} from '@mattermost/types/store'; - -import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone'; -import {getUser} from 'mattermost-redux/selectors/entities/users'; +import type {UserTimezone} from '@mattermost/types/users'; import Moon from 'components/common/svg_images_components/moon_svg'; import Timestamp from 'components/timestamp'; -import Constants from 'utils/constants'; - const Container = styled.div` display: flex; aling-items: center; @@ -45,56 +38,24 @@ const Icon = styled(Moon)` `; type Props = { - teammateId: string; displayName: string; + timestamp: number; + teammateTimezone: UserTimezone; } -const DEFAULT_TIMEZONE = { - useAutomaticTimezone: true, - automaticTimezone: '', - manualTimezone: '', -}; - -const RemoteUserHour = ({teammateId, displayName}: Props) => { - const [timestamp, setTimestamp] = useState(0); - const [showIt, setShowIt] = useState(false); - - const teammateTimezone = useSelector((state: GlobalState) => { - const teammate = teammateId ? getUser(state, teammateId) : undefined; - return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE; - }, (a, b) => a.automaticTimezone === b.automaticTimezone && - a.manualTimezone === b.manualTimezone && - a.useAutomaticTimezone === b.useAutomaticTimezone); - - useEffect(() => { - const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone); - setTimestamp(teammateUserDate.toMillis()); - setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY); - - const interval = setInterval(() => { - const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone); - setTimestamp(teammateUserDate.toMillis()); - setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY); - }, 1000 * 60); - return () => clearInterval(interval); - }, [teammateTimezone.useAutomaticTimezone, teammateTimezone.automaticTimezone, teammateTimezone.manualTimezone]); - - if (!showIt) { - return null; - } - - if (timestamp === 0) { - return null; - } - +const RemoteUserHour = ({displayName, timestamp, teammateTimezone}: Props) => { return ( - - + + + {displayName} + + ), time: ( showChannelOrThreadScheduledPostIndicator(state, id)); + + const currentTeamName = useSelector((state: GlobalState) => getCurrentTeam(state)?.name); + const scheduledPostLinkURL = `/${currentTeamName}/scheduled_posts?target_id=${id}`; + + if (!scheduledPostData?.count) { + return null; + } + + if (remoteUserHourDisplayed) { + return ( + + ); + } + + let scheduledPostText: React.ReactNode; + + // display scheduled post's details of there is only one scheduled post + if (scheduledPostData.count === 1 && scheduledPostData.scheduledPost) { + scheduledPostText = ( + + ), + }} + /> + ); + } + + // display scheduled post count if there are more than one scheduled post + if (scheduledPostData.count > 1) { + scheduledPostText = ( + + ); + } + + return ( +
+ + {scheduledPostText} + + + +
+ ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/scheduled_post_indicator/short_scheduled_post_indicator.tsx b/webapp/channels/src/components/advanced_text_editor/scheduled_post_indicator/short_scheduled_post_indicator.tsx new file mode 100644 index 0000000000..97c2aed63b --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/scheduled_post_indicator/short_scheduled_post_indicator.tsx @@ -0,0 +1,36 @@ +// 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 {Link} from 'react-router-dom'; + +import type {ChannelScheduledPostIndicatorData} from 'mattermost-redux/selectors/entities/scheduled_posts'; + +type Props = { + scheduledPostData: ChannelScheduledPostIndicatorData; + scheduledPostLinkURL: string; +} + +export function ShortScheduledPostIndicator({scheduledPostData, scheduledPostLinkURL}: Props) { + if (scheduledPostData.count === 0) { + return null; + } + + return ( +
+ ( + + {chunks} + + ), + }} + /> +
+ ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.scss b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.scss new file mode 100644 index 0000000000..0fa853e6e0 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.scss @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.DMUserTimezone { + margin-top: 8px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 12px; +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.tsx new file mode 100644 index 0000000000..b879671683 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {General} from 'mattermost-redux/constants'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; + +import Timestamp, {RelativeRanges} from 'components/timestamp'; + +import {getDisplayNameByUser, getUserIdFromChannelName} from 'utils/utils'; + +import type {GlobalState} from 'types/store'; + +import './dm_user_timezone.scss'; + +type Props = { + channelId: string; + selectedTime?: Date; +} + +const DATE_RANGES = [ + RelativeRanges.TODAY_TITLE_CASE, + RelativeRanges.TOMORROW_TITLE_CASE, +]; + +export function DMUserTimezone({channelId, selectedTime}: Props) { + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); + const dmUserId = channel && channel.type === General.DM_CHANNEL ? getUserIdFromChannelName(channel) : ''; + const dmUser = useSelector((state: GlobalState) => getUser(state, dmUserId)); + const dmUserName = useSelector((state: GlobalState) => getDisplayNameByUser(state, dmUser)); + + const dmUserTime = useMemo(() => { + if (!dmUser) { + return null; + } + + return ( + + ); + }, [dmUser, selectedTime]); + + if (!channel || channel.type !== 'D') { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx new file mode 100644 index 0000000000..f136ed9318 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment'; +import type {Moment} from 'moment-timezone'; +import React, {useCallback, useMemo, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; + +import { + DMUserTimezone, +} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone'; +import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal'; + +type Props = { + channelId: string; + onExited: () => void; + onConfirm: (timestamp: number) => Promise<{error?: string}>; + initialTime?: Moment; +} + +export default function ScheduledPostCustomTimeModal({channelId, onExited, onConfirm, initialTime}: Props) { + const {formatMessage} = useIntl(); + const [errorMessage, setErrorMessage] = useState(); + const userTimezone = useSelector(getCurrentTimezone); + const [selectedDateTime, setSelectedDateTime] = useState(() => { + if (initialTime) { + return initialTime; + } + + const now = moment().tz(userTimezone); + return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0}); + }); + + const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]); + + const handleOnConfirm = useCallback(async (dateTime: Moment) => { + const response = await onConfirm(dateTime.valueOf()); + if (response.error) { + setErrorMessage(response.error); + } else { + onExited(); + } + }, [onConfirm, onExited]); + + const bodySuffix = useMemo(() => { + return ( + + ); + }, [channelId, selectedDateTime]); + + const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'}); + + return ( + + } + subheading={userTimezoneLabel} + confirmButtonText={ + + } + cancelButtonText={ + + } + ariaLabel={label} + onExited={onExited} + onConfirm={handleOnConfirm} + onChange={setSelectedDateTime} + bodySuffix={bodySuffix} + relativeDate={true} + onCancel={onExited} + errorText={errorMessage} + /> + ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_button.scss b/webapp/channels/src/components/advanced_text_editor/send_button/send_button.scss new file mode 100644 index 0000000000..957fd9569d --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_button.scss @@ -0,0 +1,56 @@ +.splitSendButton { + display: flex; + width: 48px; + height: 32px; + flex-direction: row; + justify-content: center; + border-radius: 4px; + background: var(--button-bg); + color: var(--button-color); + + &.scheduledPost { + width: 56px; + } + + &.disabled { + background: rgba(var(--center-channel-color-rgb), 0.08); + + svg { + fill: rgba(var(--center-channel-color-rgb), 0.32); + } + + .button_send_post_options { + border-color: rgba(var(--center-channel-color-rgb), 0.16); + } + } + + .SendMessageButton, .button_send_post_options { + display: flex; + flex-direction: column; + justify-content: center; + border: none; + background: none; + + &.disabled { + cursor: not-allowed; + } + } + + .SendMessageButton { + cursor: pointer; + padding-inline: 7px; + place-content: center; + place-items: center; + transition: color 150ms; + + .android &, + .ios & { + display: flex; + } + } + + .button_send_post_options { + border-left: 1px solid color-mix(in srgb, currentColor 20%, transparent); + padding-inline: 2px; + } +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_button.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_button.tsx index b6a9b0fe08..c5f3491e33 100644 --- a/webapp/channels/src/components/advanced_text_editor/send_button/send_button.tsx +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_button.tsx @@ -1,74 +1,103 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo} from 'react'; -import {useIntl} from 'react-intl'; -import styled from 'styled-components'; +import classNames from 'classnames'; +import React, {memo, useCallback, useMemo} from 'react'; +import {defineMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import {SendIcon} from '@mattermost/compass-icons/components'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; + +import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts'; + +import {isSendOnCtrlEnter} from 'selectors/preferences'; + +import {SendPostOptions} from 'components/advanced_text_editor/send_button/send_post_options'; +import WithTooltip from 'components/with_tooltip'; +import type {ShortcutDefinition} from 'components/with_tooltip/shortcut'; +import {ShortcutKeys} from 'components/with_tooltip/shortcut'; + +import './send_button.scss'; type SendButtonProps = { - handleSubmit: () => void; + handleSubmit: (schedulingInfo?: SchedulingInfo) => void; disabled: boolean; + channelId: string; } -const SendButtonContainer = styled.button` - display: flex; - height: 32px; - padding: 0 16px; - border: none; - background: var(--button-bg); - border-radius: 4px; - color: var(--button-color); - cursor: pointer; - place-content: center; - place-items: center; - transition: color 150ms; - - &--disabled, - &[disabled] { - background: rgba(var(--center-channel-color-rgb), 0.08); - - svg { - fill: rgba(var(--center-channel-color-rgb), 0.32); - } - } - - .android &, - .ios & { - display: flex; - } -`; - -const SendButton = ({disabled, handleSubmit}: SendButtonProps) => { +const SendButton = ({disabled, handleSubmit, channelId}: SendButtonProps) => { const {formatMessage} = useIntl(); + const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); - const sendMessage = (e: React.FormEvent) => { - e.stopPropagation(); - e.preventDefault(); - handleSubmit(); - }; + const sendMessage = useCallback((e: React.FormEvent, schedulingInfo?: SchedulingInfo) => { + e?.stopPropagation(); + e?.preventDefault(); + handleSubmit(schedulingInfo); + }, [handleSubmit]); + + const sendOnCtrlEnter = useSelector(isSendOnCtrlEnter); + + const sendNowKeyboardShortcutDescriptor = useMemo(() => { + const shortcutDefinition: ShortcutDefinition = { + default: [ + defineMessage({ + id: 'shortcuts.generic.enter', + defaultMessage: 'Enter', + }), + ], + mac: [ + defineMessage({ + id: 'shortcuts.generic.enter', + defaultMessage: 'Enter', + }), + ], + }; + + if (sendOnCtrlEnter) { + shortcutDefinition.default.unshift(ShortcutKeys.ctrl); + shortcutDefinition.mac?.unshift(ShortcutKeys.cmd); + } + + return shortcutDefinition; + }, [sendOnCtrlEnter]); return ( - - - +
+ + + + + { + isScheduledPostEnabled && + + } +
); }; diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx new file mode 100644 index 0000000000..63569965b5 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment'; +import React, {memo, useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; + +import * as Menu from 'components/menu'; +import Timestamp from 'components/timestamp'; + +type Props = { + handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void; +} + +function CoreMenuOptions({handleOnSelect}: Props) { + const userTimezone = useSelector(getCurrentTimezone); + + const today = moment().tz(userTimezone); + const tomorrow9amTime = moment(). + tz(userTimezone). + add(1, 'days'). + set({hour: 9, minute: 0, second: 0, millisecond: 0}). + valueOf(); + + const timeComponent = ( + + ); + + const tomorrowClickHandler = useCallback((e) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]); + + const optionTomorrow = ( + + } + /> + ); + + const nextMonday = moment(). + tz(userTimezone). + day(8). // next monday; 1 = Monday, 8 = next Monday + set({hour: 9, minute: 0, second: 0, millisecond: 0}). // 9 AM + valueOf(); + + const nextMondayClickHandler = useCallback((e) => handleOnSelect(e, nextMonday), [handleOnSelect, nextMonday]); + + const optionNextMonday = ( + + } + /> + ); + + const optionMonday = ( + + } + /> + ); + + let options: React.ReactElement[] = []; + + switch (today.day()) { + // Sunday + case 0: + options = [optionTomorrow]; + break; + + // Monday + case 1: + options = [optionTomorrow, optionNextMonday]; + break; + + // Friday and Saturday + case 5: + case 6: + options = [optionMonday]; + break; + + // Tuesday to Thursday + default: + options = [optionTomorrow, optionMonday]; + } + + return ( + + {options} + + ); +} + +export default memo(CoreMenuOptions); diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx new file mode 100644 index 0000000000..81cc6844b8 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; + +import {openModal} from 'actions/views/modals'; + +import CoreMenuOptions from 'components/advanced_text_editor/send_button/send_post_options/core_menu_options'; +import * as Menu from 'components/menu'; + +import {ModalIdentifiers} from 'utils/constants'; + +import ScheduledPostCustomTimeModal from '../scheduled_post_custom_time_modal/scheduled_post_custom_time_modal'; + +import './style.scss'; + +type Props = { + channelId: string; + disabled?: boolean; + onSelect: (schedulingInfo: SchedulingInfo) => void; +} + +export function SendPostOptions({disabled, onSelect, channelId}: Props) { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + const handleOnSelect = useCallback((e: React.FormEvent, scheduledAt: number) => { + e.preventDefault(); + e.stopPropagation(); + + const schedulingInfo: SchedulingInfo = { + scheduled_at: scheduledAt, + }; + + onSelect(schedulingInfo); + }, [onSelect]); + + const handleSelectCustomTime = useCallback((scheduledAt: number) => { + const schedulingInfo: SchedulingInfo = { + scheduled_at: scheduledAt, + }; + + onSelect(schedulingInfo); + return Promise.resolve({}); + }, [onSelect]); + + const handleChooseCustomTime = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL, + dialogType: ScheduledPostCustomTimeModal, + dialogProps: { + channelId, + onConfirm: handleSelectCustomTime, + }, + })); + }, [channelId, dispatch, handleSelectCustomTime]); + + return ( + , + disabled, + 'aria-label': formatMessage({ + id: 'create_post_button.option.schedule_message', + defaultMessage: 'Schedule message', + }), + }} + menu={{ + id: 'dropdown_send_post_options', + }} + transformOrigin={{ + horizontal: 'right', + vertical: 'bottom', + }} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + > + + } + /> + + + + + + + } + /> + + + ); +} diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss new file mode 100644 index 0000000000..efe35b2190 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss @@ -0,0 +1,9 @@ +ul#dropdown_send_post_options { + li[role="menuitem"][aria-disabled="true"] { + opacity: 1; + + .label-elements { + font-weight: bold; + } + } +} diff --git a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx index d7dea5c0ac..897ab25fb8 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx @@ -5,6 +5,8 @@ import type React from 'react'; import {useCallback, useEffect, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; + import {getBool} from 'mattermost-redux/selectors/entities/preferences'; import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; @@ -39,7 +41,7 @@ const useKeyHandler = ( focusTextbox: (forceFocus?: boolean) => void, applyMarkdown: (params: ApplyMarkdownOptions) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, - handleSubmit: (submittingDraft?: PostDraft) => void, + handleSubmit: (submittingDraft?: PostDraft, schedulingInfo?: SchedulingInfo) => void, emitTypingEvent: () => void, toggleShowPreview: () => void, toggleAdvanceTextEditor: () => void, diff --git a/webapp/channels/src/components/advanced_text_editor/use_priority.tsx b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx index 4c67777d03..10b4dd7619 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_priority.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx @@ -27,16 +27,18 @@ import PriorityLabels from './priority_labels'; const usePriority = ( draft: PostDraft, - handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void, + handleDraftChange: ((draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void), focusTextbox: (keepFocus?: boolean) => void, shouldShowPreview: boolean, ) => { const dispatch = useDispatch(); + const rootId = draft.rootId; + const channelId = draft.channelId; const isPostPriorityEnabled = useSelector(isPostPriorityEnabledSelector); - const channelType = useSelector((state: GlobalState) => getChannel(state, draft.channelId)?.type || 'O'); + const channelType = useSelector((state: GlobalState) => getChannel(state, channelId)?.type || 'O'); const channelTeammateUsername = useSelector((state: GlobalState) => { - const channel = getChannel(state, draft.channelId); + const channel = getChannel(state, channelId); return getUser(state, channel?.teammate_id || '')?.username || ''; }); @@ -133,7 +135,7 @@ const usePriority = ( }, [isPostPriorityEnabled, showPersistNotificationModal, draft, channelType, specialMentions]); const labels = useMemo(() => ( - (hasPrioritySet && !draft.rootId) ? ( + (hasPrioritySet && !rootId) ? ( ) : undefined - ), [shouldShowPreview, draft, hasPrioritySet, isValidPersistentNotifications, specialMentions, handleRemovePriority]); + ), [hasPrioritySet, rootId, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]); const additionalControl = useMemo(() => - !draft.rootId && isPostPriorityEnabled && ( + !rootId && isPostPriorityEnabled && ( - ), [draft.rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]); + ), [rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]); return { labels, diff --git a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx index a4508579f4..94a55c2f88 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx @@ -6,6 +6,7 @@ import {useCallback, useRef, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import type {ServerError} from '@mattermost/types/errors'; +import type {SchedulingInfo} from '@mattermost/types/schedule_post'; import {getChannelTimezones} from 'mattermost-redux/actions/channels'; import {Permissions} from 'mattermost-redux/constants'; @@ -15,6 +16,7 @@ import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import type {CreatePostOptions} from 'actions/post_actions'; import {scrollPostListToBottom} from 'actions/views/channel'; import type {OnSubmitOptions, SubmitPostReturnType} from 'actions/views/create_comment'; import {onSubmit} from 'actions/views/create_comment'; @@ -64,7 +66,7 @@ const useSubmit = ( afterSubmit?: (response: SubmitPostReturnType) => void, skipCommands?: boolean, ): [ - (submittingDraft?: PostDraft) => Promise, + (submittingDraft?: PostDraft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => void, string | null, ] => { const getGroupMentions = useGroups(channelId, draft.message); @@ -118,7 +120,7 @@ const useSubmit = ( })); }, [dispatch]); - const doSubmit = useCallback(async (submittingDraft = draft) => { + const doSubmit = useCallback(async (submittingDraft: PostDraft = draft, schedulingInfo?: SchedulingInfo, createPostOptions?: CreatePostOptions) => { if (submittingDraft.uploadsInProgress.length > 0) { isDraftSubmitting.current = false; return; @@ -138,10 +140,12 @@ const useSubmit = ( return; } - if (isRootDeleted) { - showPostDeletedModal(); - isDraftSubmitting.current = false; - return; + if (!schedulingInfo) { + if (isRootDeleted) { + showPostDeletedModal(); + isDraftSubmitting.current = false; + return; + } } if (serverError && !isErrorInvalidSlashCommand(serverError)) { @@ -156,10 +160,15 @@ const useSubmit = ( setServerError(null); const ignoreSlash = skipCommands || (isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message); - const options: OnSubmitOptions = {ignoreSlash, afterSubmit, afterOptimisticSubmit}; + const options: OnSubmitOptions = { + ignoreSlash, + afterSubmit, + afterOptimisticSubmit, + keepDraft: createPostOptions?.keepDraft, + }; try { - const res = await dispatch(onSubmit(submittingDraft, options)); + const res = await dispatch(onSubmit(submittingDraft, options, schedulingInfo)); if (res.error) { throw res.error; } @@ -190,7 +199,7 @@ const useSubmit = ( return; } - if (!postId) { + if (!postId && !schedulingInfo) { dispatch(scrollPostListToBottom()); } @@ -212,7 +221,7 @@ const useSubmit = ( channelId, ]); - const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { + const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number, onConfirm: () => void) => { dispatch(openModal({ modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL, dialogType: NotifyConfirmModal, @@ -220,12 +229,12 @@ const useSubmit = ( mentions, channelTimezoneCount, memberNotifyCount, - onConfirm: () => doSubmit(), + onConfirm, }, })); - }, [doSubmit, dispatch]); + }, [dispatch]); - const handleSubmit = useCallback(async (submittingDraft = draft) => { + const handleSubmit = useCallback(async (submittingDraft = draft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => { if (!channel) { return; } @@ -262,18 +271,19 @@ const useSubmit = ( channelTimezoneCount = data ? data.length : 0; } - if (prioritySubmitCheck(doSubmit)) { + const onConfirm = () => doSubmit(submittingDraft, schedulingInfo); + if (prioritySubmitCheck(onConfirm)) { isDraftSubmitting.current = false; return; } if (memberNotifyCount > 0) { - showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount); + showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount, onConfirm); isDraftSubmitting.current = false; return; } - if (!skipCommands) { + if (!skipCommands && !schedulingInfo) { const status = getStatusFromSlashCommand(submittingDraft.message); if (userIsOutOfOffice && status) { const resetStatusModalData = { @@ -327,7 +337,7 @@ const useSubmit = ( } } - await doSubmit(submittingDraft); + await doSubmit(submittingDraft, schedulingInfo, options); }, [ doSubmit, draft, diff --git a/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx b/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx index cf4de85022..3b80ef7986 100644 --- a/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx +++ b/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx @@ -9,6 +9,7 @@ import {makeAsyncComponent} from 'components/async_load'; import ChannelIdentifierRouter from 'components/channel_layout/channel_identifier_router'; import LoadingScreen from 'components/loading_screen'; +import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; import {IDENTIFIER_PATH_PATTERN, ID_PATH_PATTERN, TEAM_NAME_PATH_PATTERN} from 'utils/path'; import type {OwnProps, PropsFromRedux} from './index'; @@ -114,6 +115,10 @@ export default class CenterChannel extends React.PureComponent { path={`/:team(${TEAM_NAME_PATH_PATTERN})/drafts`} component={Drafts} /> + diff --git a/webapp/channels/src/components/common/hooks/use_scroll_on_render.ts b/webapp/channels/src/components/common/hooks/use_scroll_on_render.ts new file mode 100644 index 0000000000..3109de4b20 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/use_scroll_on_render.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +// useScrollOnRender hook is used to scroll to the element when it is rendered +// Attach the returned ref to the element you want to scroll to. +export function useScrollOnRender() { + const ref = React.useRef(null); + + React.useEffect(() => { + if (ref.current) { + ref.current.scrollIntoView({behavior: 'smooth'}); + } + }, []); + + return ref; +} diff --git a/webapp/channels/src/components/custom_status/custom_status.scss b/webapp/channels/src/components/custom_status/custom_status.scss index 7c0bc086a6..981da22d10 100644 --- a/webapp/channels/src/components/custom_status/custom_status.scss +++ b/webapp/channels/src/components/custom_status/custom_status.scss @@ -328,9 +328,14 @@ span.emoticon[style]:hover { &-icon { position: absolute; - top: 11.3px; + top: 9.5px; left: 15px; } + + .dateTime__input { + display: flex; + align-items: center; + } } & &__input { diff --git a/webapp/channels/src/components/custom_status/date_time_input.tsx b/webapp/channels/src/components/custom_status/date_time_input.tsx index 8d9a754cde..1584a36226 100644 --- a/webapp/channels/src/components/custom_status/date_time_input.tsx +++ b/webapp/channels/src/components/custom_status/date_time_input.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import classNames from 'classnames'; import {DateTime} from 'luxon'; import type {Moment} from 'moment-timezone'; import moment from 'moment-timezone'; @@ -22,15 +23,17 @@ import Input from 'components/widgets/inputs/input/input'; import Menu from 'components/widgets/menu/menu'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; -import Constants, {A11yCustomEventTypes} from 'utils/constants'; import type {A11yFocusEventDetail} from 'utils/constants'; +import Constants, {A11yCustomEventTypes} from 'utils/constants'; +import {relativeFormatDate} from 'utils/datetime'; import {isKeyPressed} from 'utils/keyboard'; import {getCurrentMomentForTimezone} from 'utils/timezone'; const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30; -export function getRoundedTime(value: Moment) { - const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES; +const DATE_FORMAT = 'yyyy-MM-dd'; + +export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES) { const start = moment(value); const diff = start.minute() % roundedTo; if (diff === 0) { @@ -40,8 +43,7 @@ export function getRoundedTime(value: Moment) { return start.add(remainder, 'm').seconds(0).milliseconds(0); } -export const getTimeInIntervals = (startTime: Moment): Date[] => { - const interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES; +export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Date[] => { let time = moment(startTime); const nextDay = moment(startTime).add(1, 'days').startOf('day'); @@ -65,6 +67,8 @@ type Props = { handleChange: (date: Moment) => void; timezone?: string; setIsDatePickerOpen?: (isDatePickerOpen: boolean) => void; + relativeDate?: boolean; + timePickerInterval?: number; } const DateTimeInputContainer: React.FC = (props: Props) => { @@ -99,9 +103,9 @@ const DateTimeInputContainer: React.FC = (props: Props) => { const currentTime = getCurrentMomentForTimezone(timezone); let startTime = moment(time).startOf('day'); if (currentTime.isSame(time, 'date')) { - startTime = getRoundedTime(currentTime); + startTime = getRoundedTime(currentTime, props.timePickerInterval); } - setTimeOptions(getTimeInIntervals(startTime)); + setTimeOptions(getTimeInIntervals(startTime, props.timePickerInterval)); }; useEffect(setTimeAndOptions, [time]); @@ -109,10 +113,10 @@ const DateTimeInputContainer: React.FC = (props: Props) => { const handleDayChange = (day: Date, modifiers: DayModifiers) => { if (modifiers.today) { const currentTime = getCurrentMomentForTimezone(timezone); - const roundedTime = getRoundedTime(currentTime); + const roundedTime = getRoundedTime(currentTime, props.timePickerInterval); handleChange(roundedTime); } else { - const dayWithTimezone = timezone ? moment.tz(day, timezone) : moment(day); + const dayWithTimezone = timezone ? moment(day).tz(timezone, true) : moment(day); handleChange(dayWithTimezone.startOf('day')); } handlePopperOpenState(false); @@ -137,8 +141,8 @@ const DateTimeInputContainer: React.FC = (props: Props) => { )); }, []); - const formatDate = (date: Date): string => { - return DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'); + const formatDate = (date: Moment): string => { + return props.relativeDate ? relativeFormatDate(date, formatMessage, DATE_FORMAT) : DateTime.fromJSDate(date.toDate()).toFormat(DATE_FORMAT); }; const inputIcon = ( @@ -174,10 +178,10 @@ const DateTimeInputContainer: React.FC = (props: Props) => { datePickerProps={datePickerProps} > handlePopperOpenState(true)} tabIndex={-1} diff --git a/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx new file mode 100644 index 0000000000..c32cbb166f --- /dev/null +++ b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classnames from 'classnames'; +import type {Moment} from 'moment-timezone'; +import React, {useCallback, useEffect, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import {GenericModal} from '@mattermost/components'; + +import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; + +import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input'; + +import Constants from 'utils/constants'; +import {isKeyPressed} from 'utils/keyboard'; +import {getCurrentMomentForTimezone} from 'utils/timezone'; + +import './style.scss'; + +type Props = { + onExited?: () => void; + ariaLabel: string; + header: React.ReactNode; + subheading?: React.ReactNode; + onChange?: (dateTime: Moment) => void; + onCancel?: () => void; + onConfirm?: (dateTime: Moment) => void; + initialTime?: Moment; + confirmButtonText?: React.ReactNode; + cancelButtonText?: React.ReactNode; + bodyPrefix?: React.ReactNode; + bodySuffix?: React.ReactNode; + relativeDate?: boolean; + className?: string; + errorText?: string | React.ReactNode; + timePickerInterval?: number; +}; + +export default function DateTimePickerModal({ + onExited, + ariaLabel, + header, + onConfirm, + onCancel, + initialTime, + confirmButtonText, + onChange, + cancelButtonText, + subheading, + bodyPrefix, + bodySuffix, + relativeDate, + className, + errorText, + timePickerInterval, +}: Props) { + const userTimezone = useSelector(getCurrentTimezone); + const currentTime = getCurrentMomentForTimezone(userTimezone); + const initialRoundedTime = getRoundedTime(currentTime); + + const [dateTime, setDateTime] = useState(initialTime || initialRoundedTime); + + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isDatePickerOpen) { + event.preventDefault(); + event.stopPropagation(); + onExited?.(); + } + } + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isDatePickerOpen, onExited]); + + const handleChange = useCallback((dateTime: Moment) => { + setDateTime(dateTime); + onChange?.(dateTime); + }, [onChange]); + + const handleConfirm = useCallback(() => { + onConfirm?.(dateTime); + }, [dateTime, onConfirm]); + + return ( + + {bodyPrefix} + + + + {bodySuffix} + + ); +} diff --git a/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.scss b/webapp/channels/src/components/date_time_picker_modal/style.scss similarity index 56% rename from webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.scss rename to webapp/channels/src/components/date_time_picker_modal/style.scss index 32fc814b4d..0672bdd677 100644 --- a/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.scss +++ b/webapp/channels/src/components/date_time_picker_modal/style.scss @@ -1,18 +1,12 @@ -.post-reminder-modal { +.date-time-picker-modal { .modal-body { overflow: visible; } .dateTime__calendar-input { width: 100%; - border: 2px solid var(--button-bg); font-size: 14px; - &:hover { - border: 2px solid var(--button-bg); - cursor: pointer; - } - &:focus-within { box-shadow: 0; } @@ -44,6 +38,36 @@ } } + .dateTime__time-menu .dateTime__input, .dateTime__calendar-input { + height: 40px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + + &:hover, &.isOpen { + border-color: rgba(var(--center-channel-color-rgb), 0.16); + box-shadow: 0 0 0 2px var(--button-bg); + cursor: pointer; + } + + .Input_wrapper { + button:hover { + background: none; + } + } + } + + .dateTime__time-menu { + &.MenuWrapper--open { + .dateTime__input { + box-shadow: 0 0 0 2px var(--button-bg); + cursor: pointer; + } + } + + .dateTime__time-icon { + pointer-events: none; + } + } + .dateTime { width: 100%; margin: unset; diff --git a/webapp/channels/src/components/drafts/__snapshots__/draft_row.test.tsx.snap b/webapp/channels/src/components/drafts/__snapshots__/draft_row.test.tsx.snap index e3407af6f6..8fad395bc6 100644 --- a/webapp/channels/src/components/drafts/__snapshots__/draft_row.test.tsx.snap +++ b/webapp/channels/src/components/drafts/__snapshots__/draft_row.test.tsx.snap @@ -34,12 +34,8 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1` > @@ -82,10 +78,11 @@ exports[`components/drafts/drafts_row should match snapshot for thread draft 1`] displayName="test" draft={ Object { - "type": "thread", + "rootId": "some_id", } } isRemote={false} + item={Object {}} status={Object {}} user={Object {}} /> diff --git a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap index 43b5ba22a5..2589ed4410 100644 --- a/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap +++ b/webapp/channels/src/components/drafts/draft_actions/__snapshots__/draft_actions.test.tsx.snap @@ -35,10 +35,13 @@ exports[`components/drafts/draft_actions should match snapshot 1`] = ` diff --git a/webapp/channels/src/components/drafts/draft_actions/delete_draft_modal.tsx b/webapp/channels/src/components/drafts/draft_actions/delete_draft_modal.tsx index 76bee8db7f..15a202cd5a 100644 --- a/webapp/channels/src/components/drafts/draft_actions/delete_draft_modal.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/delete_draft_modal.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import noop from 'lodash/noop'; import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; @@ -43,7 +44,7 @@ function DeleteDraftModal({ return ( {}} + handleCancel={noop} handleConfirm={onConfirm} modalHeaderText={title} onExited={onExited} diff --git a/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx b/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx index f415f4112f..eabe850e06 100644 --- a/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/draft_actions.test.tsx @@ -13,11 +13,14 @@ describe('components/drafts/draft_actions', () => { const baseProps = { displayName: '', draftId: '', + itemId: '', onDelete: jest.fn(), onEdit: jest.fn(), onSend: jest.fn(), canSend: true, canEdit: true, + onSchedule: jest.fn(), + channelId: '', }; it('should match snapshot', () => { diff --git a/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx b/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx index d35b8da7f8..0745374bf5 100644 --- a/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx +++ b/webapp/channels/src/components/drafts/draft_actions/draft_actions.tsx @@ -7,12 +7,22 @@ import {useDispatch} from 'react-redux'; import {openModal} from 'actions/views/modals'; +import ScheduledPostCustomTimeModal + from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal'; + import {ModalIdentifiers} from 'utils/constants'; import Action from './action'; import DeleteDraftModal from './delete_draft_modal'; import SendDraftModal from './send_draft_modal'; +const scheduledDraft = ( + +); + type Props = { displayName: string; onDelete: () => void; @@ -20,6 +30,8 @@ type Props = { onSend: () => void; canEdit: boolean; canSend: boolean; + onSchedule: (timestamp: number) => Promise<{error?: string}>; + channelId: string; } function DraftActions({ @@ -29,6 +41,8 @@ function DraftActions({ onSend, canEdit, canSend, + onSchedule, + channelId, }: Props) { const dispatch = useDispatch(); @@ -54,6 +68,17 @@ function DraftActions({ })); }, [dispatch, displayName, onSend]); + const handleScheduleDraft = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL, + dialogType: ScheduledPostCustomTimeModal, + dialogProps: { + channelId, + onConfirm: onSchedule, + }, + })); + }, [channelId, dispatch, onSchedule]); + return ( <> )} + + { + canSend && + + } + {canSend && ( Promise<{error?: string}>; + onExited: () => void; +} + +export default function DeleteScheduledPostModal({ + channelDisplayName, + onExited, + onConfirm, +}: Props) { + const {formatMessage} = useIntl(); + const [errorMessage, setErrorMessage] = useState(); + + const title = formatMessage({ + id: 'scheduled_post.delete_modal.title', + defaultMessage: 'Delete scheduled post', + }); + + const confirmButtonText = formatMessage({ + id: 'drafts.confirm.delete.button', + defaultMessage: 'Yes, delete', + }); + + const handleOnConfirm = useCallback(async () => { + const response = await onConfirm(); + if (response.error) { + setErrorMessage(response.error); + } else { + onExited(); + } + }, [onConfirm, onExited]); + + return ( + + {displayName}?'} + values={{ + strong: (chunk: string) => {chunk}, + displayName: channelDisplayName, + }} + /> + + ); +} diff --git a/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx new file mode 100644 index 0000000000..84a7df252a --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment'; +import React, {memo, useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; + +import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; + +import {openModal} from 'actions/views/modals'; + +import ScheduledPostCustomTimeModal + from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal'; +import Action from 'components/drafts/draft_actions/action'; +import DeleteScheduledPostModal + from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal'; +import SendDraftModal from 'components/drafts/draft_actions/send_draft_modal'; + +import {ModalIdentifiers} from 'utils/constants'; + +import './style.scss'; + +const deleteTooltipText = ( + +); + +const editTooltipText = ( + +); + +const rescheduleTooltipText = ( + +); + +const sendNowTooltipText = ( + +); + +type Props = { + scheduledPost: ScheduledPost; + channelDisplayName: string; + onReschedule: (timestamp: number) => Promise<{error?: string}>; + onDelete: (scheduledPostId: string) => Promise<{error?: string}>; + onSend: (scheduledPostId: string) => void; + onEdit: () => void; +} + +function ScheduledPostActions({scheduledPost, onReschedule, onDelete, channelDisplayName, onSend, onEdit}: Props) { + const dispatch = useDispatch(); + const userTimezone = useSelector(getCurrentTimezone); + + const handleReschedulePost = useCallback(() => { + const initialTime = moment.tz(scheduledPost.scheduled_at, userTimezone); + + dispatch(openModal({ + modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL, + dialogType: ScheduledPostCustomTimeModal, + dialogProps: { + channelId: scheduledPost.channel_id, + onConfirm: onReschedule, + initialTime, + }, + })); + }, [dispatch, onReschedule, scheduledPost.channel_id, scheduledPost.scheduled_at, userTimezone]); + + const handleDelete = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.DELETE_DRAFT, + dialogType: DeleteScheduledPostModal, + dialogProps: { + channelDisplayName, + onConfirm: () => onDelete(scheduledPost.id), + }, + })); + }, [channelDisplayName, dispatch, onDelete, scheduledPost.id]); + + const handleSend = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.SEND_DRAFT, + dialogType: SendDraftModal, + dialogProps: { + displayName: channelDisplayName, + onConfirm: () => onSend(scheduledPost.id), + }, + })); + }, [channelDisplayName, dispatch, onSend, scheduledPost.id]); + + return ( +
+ + + { + !scheduledPost.error_code && ( + + + + + + + + ) + } + +
+ ); +} + +export default memo(ScheduledPostActions); diff --git a/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/style.scss b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/style.scss new file mode 100644 index 0000000000..ef424b17a1 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_actions/schedule_post_actions/style.scss @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.ScheduledPostActions { + display: inline-flex; +} diff --git a/webapp/channels/src/components/drafts/draft_list/draft_list.tsx b/webapp/channels/src/components/drafts/draft_list/draft_list.tsx new file mode 100644 index 0000000000..c17a8f8448 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_list/draft_list.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import type {Draft} from 'selectors/drafts'; + +import DraftRow from 'components/drafts/draft_row'; +import DraftsIllustration from 'components/drafts/drafts_illustration'; +import NoResultsIndicator from 'components/no_results_indicator'; + +type Props = { + drafts: Draft[]; + user: UserProfile; + displayName: string; + draftRemotes: Record; + status: UserStatus['status']; + className?: string; +} + +export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) { + const {formatMessage} = useIntl(); + + return ( +
+ {drafts.map((d) => ( + + ))} + {drafts.length === 0 && ( + + )} +
+ ); +} diff --git a/webapp/channels/src/components/drafts/draft_row.scss b/webapp/channels/src/components/drafts/draft_row.scss new file mode 100644 index 0000000000..45b72ca545 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_row.scss @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.Panel { + .post--editing__wrapper { + margin: 16px; + } +} diff --git a/webapp/channels/src/components/drafts/draft_row.test.tsx b/webapp/channels/src/components/drafts/draft_row.test.tsx index e0298506f6..db65869a90 100644 --- a/webapp/channels/src/components/drafts/draft_row.test.tsx +++ b/webapp/channels/src/components/drafts/draft_row.test.tsx @@ -2,26 +2,26 @@ // See LICENSE.txt for license information. import {shallow} from 'enzyme'; +import type {ComponentProps} from 'react'; import React from 'react'; import {Provider} from 'react-redux'; import type {UserProfile, UserStatus} from '@mattermost/types/users'; -import type {Draft} from 'selectors/drafts'; - import mockStore from 'tests/test_store'; +import type {PostDraft} from 'types/store/draft'; + import DraftRow from './draft_row'; describe('components/drafts/drafts_row', () => { - const baseProps = { - draft: { - type: 'channel', - } as Draft, + const baseProps: ComponentProps = { + item: {} as PostDraft, user: {} as UserProfile, status: {} as UserStatus['status'], displayName: 'test', isRemote: false, + }; it('should match snapshot for channel draft', () => { @@ -42,10 +42,7 @@ describe('components/drafts/drafts_row', () => { const props = { ...baseProps, - draft: { - ...baseProps.draft, - type: 'thread', - } as Draft, + draft: {rootId: 'some_id'} as PostDraft, }; const wrapper = shallow( diff --git a/webapp/channels/src/components/drafts/draft_row.tsx b/webapp/channels/src/components/drafts/draft_row.tsx index fbf7ae80a3..43b3353b8f 100644 --- a/webapp/channels/src/components/drafts/draft_row.tsx +++ b/webapp/channels/src/components/drafts/draft_row.tsx @@ -2,15 +2,18 @@ // See LICENSE.txt for license information. import noop from 'lodash/noop'; -import React, {memo, useCallback, useMemo, useEffect, useState} from 'react'; +import React, {memo, useCallback, useMemo, useEffect, useState, useRef} from 'react'; import {useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import {useHistory} from 'react-router-dom'; import type {ServerError} from '@mattermost/types/errors'; +import type {FileInfo} from '@mattermost/types/files'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; import type {UserProfile, UserStatus} from '@mattermost/types/users'; import {getPost as getPostAction} from 'mattermost-redux/actions/posts'; +import {deleteScheduledPost, updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts'; import {Permissions} from 'mattermost-redux/constants'; import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; @@ -19,52 +22,70 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles' import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads'; +import type {SubmitPostReturnType} from 'actions/views/create_comment'; import {removeDraft} from 'actions/views/drafts'; import {selectPostById} from 'actions/views/rhs'; -import type {Draft} from 'selectors/drafts'; +import {getConnectionId} from 'selectors/general'; import {getChannelURL} from 'selectors/urls'; import usePriority from 'components/advanced_text_editor/use_priority'; import useSubmit from 'components/advanced_text_editor/use_submit'; +import {useScrollOnRender} from 'components/common/hooks/use_scroll_on_render'; +import ScheduledPostActions from 'components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions'; +import PlaceholderScheduledPostsTitle + from 'components/drafts/placeholder_scheduled_post_title/placeholder_scheduled_posts_title'; +import EditPost from 'components/edit_post'; import Constants, {StoragePrefixes} from 'utils/constants'; import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; +import {scheduledPostToPostDraft} from 'types/store/draft'; import DraftActions from './draft_actions'; import DraftTitle from './draft_title'; import Panel from './panel/panel'; import PanelBody from './panel/panel_body'; import Header from './panel/panel_header'; +import {getErrorStringFromCode} from './utils'; + +import './draft_row.scss'; type Props = { user: UserProfile; status: UserStatus['status']; displayName: string; - draft: Draft; + item: PostDraft | ScheduledPost; isRemote?: boolean; + scrollIntoView?: boolean; } const mockLastBlurAt = {current: 0}; function DraftRow({ - draft, + item, user, status, displayName, isRemote, + scrollIntoView, }: Props) { + const [isEditing, setIsEditing] = useState(false); + + const isScheduledPost = 'scheduled_at' in item; const intl = useIntl(); - const rootId = draft.value.rootId; - const channelId = draft.value.channelId; + const rootId = ('rootId' in item) ? item.rootId : item.root_id; + const channelId = ('channelId' in item) ? item.channelId : item.channel_id; const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null); const history = useHistory(); const dispatch = useDispatch(); - const getChannel = useMemo(() => makeGetChannel(), []); + const getChannelSelector = useMemo(() => makeGetChannel(), []); + const channel = useSelector((state: GlobalState) => getChannelSelector(state, channelId)); + const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []); const rootPostDeleted = useSelector((state: GlobalState) => { @@ -77,16 +98,24 @@ function DraftRow({ const tooLong = useSelector((state: GlobalState) => { const maxPostSize = parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT; - return draft.value.message.length > maxPostSize; + return item.message.length > maxPostSize; }); const readOnly = !useSelector((state: GlobalState) => { - const channel = getChannel(state, channelId); return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST) : false; }); + const connectionId = useSelector(getConnectionId); + let postError = ''; - if (rootPostDeleted) { + + if (isScheduledPost) { + // This is applicable only for scheduled post. + if (item.error_code) { + postError = getErrorStringFromCode(intl, item.error_code); + postError = getErrorStringFromCode(intl, item.error_code); + } + } else if (rootPostDeleted) { postError = intl.formatMessage({id: 'drafts.error.post_not_found', defaultMessage: 'Thread not found'}); } else if (tooLong) { postError = intl.formatMessage({id: 'drafts.error.too_long', defaultMessage: 'Message too long'}); @@ -97,17 +126,19 @@ function DraftRow({ const canSend = !postError; const canEdit = !(rootPostDeleted || readOnly); - const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); const channelUrl = useSelector((state: GlobalState) => { if (!channel) { return ''; } - const teamId = getCurrentTeamId(state); return getChannelURL(state, channel, teamId); }); const goToMessage = useCallback(async () => { + if (isEditing) { + return; + } + if (rootId) { if (rootPostDeleted) { return; @@ -116,25 +147,10 @@ function DraftRow({ return; } history.push(channelUrl); - }, [channelUrl, dispatch, history, rootId, rootPostDeleted]); + }, [channelUrl, dispatch, history, rootId, rootPostDeleted, isEditing]); - const {onSubmitCheck: prioritySubmitCheck} = usePriority(draft.value, noop, noop, false); - const [handleOnSend] = useSubmit( - draft.value, - postError, - channelId, - rootId, - serverError, - mockLastBlurAt, - noop, - setServerError, - noop, - noop, - prioritySubmitCheck, - goToMessage, - undefined, - true, - ); + const isBeingScheduled = useRef(false); + const isScheduledPostBeingSent = useRef(false); const thread = useSelector((state: GlobalState) => { if (!rootId) { @@ -156,59 +172,229 @@ function DraftRow({ dispatch(removeDraft(key, channelId, rootId)); }, [dispatch, channelId, rootId]); + const afterSubmit = useCallback((response: SubmitPostReturnType) => { + // if draft was being scheduled, delete the draft after it's been scheduled + if (isBeingScheduled.current && response.created && !response.error) { + handleOnDelete(); + isBeingScheduled.current = false; + } + + // if scheduled posts was being sent, delete the scheduled post after it's been sent + if (isScheduledPostBeingSent.current && response.created && !response.error) { + dispatch(deleteScheduledPost((item as ScheduledPost).id, connectionId)); + isScheduledPostBeingSent.current = false; + } + }, [connectionId, dispatch, handleOnDelete, item]); + + // TODO LOL verify the types and handled it better + const {onSubmitCheck: prioritySubmitCheck} = usePriority(item as any, noop, noop, false); + const [handleOnSend] = useSubmit( + item as any, + postError, + channelId, + rootId, + serverError, + mockLastBlurAt, + noop, + setServerError, + noop, + noop, + prioritySubmitCheck, + goToMessage, + afterSubmit, + true, + ); + + const onScheduleDraft = useCallback(async (scheduledAt: number): Promise<{error?: string}> => { + isBeingScheduled.current = true; + await handleOnSend(item as PostDraft, {scheduled_at: scheduledAt}); + return Promise.resolve({}); + }, [item, handleOnSend]); + + const draftActions = useMemo(() => { + if (!channel) { + return null; + } + return ( + + ); + }, [ + canEdit, + canSend, + channel, + goToMessage, + handleOnDelete, + handleOnSend, + user.id, + onScheduleDraft, + ]); + + const handleCancelEdit = useCallback(() => { + setIsEditing(false); + }, []); + + const handleSchedulePostOnReschedule = useCallback(async (updatedScheduledAtTime: number) => { + handleCancelEdit(); + + const updatedScheduledPost: ScheduledPost = { + ...(item as ScheduledPost), + scheduled_at: updatedScheduledAtTime, + }; + + const result = await dispatch(updateScheduledPost(updatedScheduledPost, connectionId)); + return { + error: result.error?.message, + }; + }, [connectionId, dispatch, item, handleCancelEdit]); + + const handleSchedulePostOnDelete = useCallback(async () => { + handleCancelEdit(); + + const scheduledPostId = (item as ScheduledPost).id; + const result = await dispatch(deleteScheduledPost(scheduledPostId, connectionId)); + return { + error: result.error?.message, + }; + }, [item, dispatch, connectionId, handleCancelEdit]); + + const handleSchedulePostEdit = useCallback(() => { + setIsEditing((isEditing) => !isEditing); + }, []); + + const handleScheduledPostOnSend = useCallback(() => { + handleCancelEdit(); + + isScheduledPostBeingSent.current = true; + const postDraft = scheduledPostToPostDraft(item as ScheduledPost); + handleOnSend(postDraft, undefined, {keepDraft: true}); + return Promise.resolve({}); + }, [handleOnSend, item, handleCancelEdit]); + + const scheduledPostActions = useMemo(() => { + if (!channel) { + return null; + } + + return ( + + ); + }, [ + channel, + handleSchedulePostOnDelete, + handleSchedulePostOnReschedule, + handleScheduledPostOnSend, + handleSchedulePostEdit, + item, + ]); + useEffect(() => { if (rootId && !thread?.id) { dispatch(getPostAction(rootId)); } - }, [thread?.id]); + }, [thread?.id, rootId]); - if (!channel) { + const alertRef = useScrollOnRender(); + + if (!channel && !isScheduledPost) { return null; } + let timestamp: number; + let fileInfos: FileInfo[]; + let uploadsInProgress: string[]; + let actions: React.ReactNode; + + if (isScheduledPost) { + timestamp = item.scheduled_at; + fileInfos = item.metadata?.files || []; + uploadsInProgress = []; + actions = scheduledPostActions; + } else { + timestamp = item.updateAt; + fileInfos = item.fileInfos; + uploadsInProgress = item.uploadsInProgress; + actions = draftActions; + } + + let title: React.ReactNode; + if (channel) { + title = ( + + ); + } else { + title = ( + + ); + } + return ( {({hover}) => ( <>
- )} - title={( - - )} - timestamp={draft.value.updateAt} + actions={actions} + title={title} + timestamp={timestamp} remote={isRemote || false} error={postError || serverError?.message} /> - + + { + isEditing && + + } + + { + !isEditing && + + } )} diff --git a/webapp/channels/src/components/drafts/drafts.scss b/webapp/channels/src/components/drafts/drafts.scss index 45fbba32a9..07c8c6ff3e 100644 --- a/webapp/channels/src/components/drafts/drafts.scss +++ b/webapp/channels/src/components/drafts/drafts.scss @@ -20,7 +20,6 @@ height: 100%; flex-flow: column nowrap; padding: 24px; - grid-area: list; } display: grid; @@ -37,6 +36,46 @@ margin-bottom: 20px; } + #draft_tabs { + grid-area: list; + + .nav.nav-tabs { + .drafts_tab { + .badge { + position: relative; + top: -2px; + display: inline-block; + height: 14px; + margin-left: 6px; + background: rgba(var(--center-channel-color-rgb), 0.2); + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 11px; + } + + &.active { + .badge { + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + } + } + } + } + + .tab-content { + height: 100%; + + div[role="tabpanel"] { + position: relative; + height: 100%; + } + } + } + + span.MuiBadge-badge { + position: relative; + top: -2px; + } + @media screen and (max-width: 768px) { grid-template-rows: 0 1fr; diff --git a/webapp/channels/src/components/drafts/drafts.tsx b/webapp/channels/src/components/drafts/drafts.tsx index abc7d02391..42ce18578d 100644 --- a/webapp/channels/src/components/drafts/drafts.tsx +++ b/webapp/channels/src/components/drafts/drafts.tsx @@ -1,26 +1,40 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo, useEffect} from 'react'; -import {useIntl} from 'react-intl'; -import {useDispatch} from 'react-redux'; +import {Badge} from '@mui/base'; +import React, {memo, useCallback, useEffect, useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import {type match, useHistory, useRouteMatch} from 'react-router-dom'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; import type {UserProfile, UserStatus} from '@mattermost/types/users'; +import { + isScheduledPostsEnabled, + makeGetScheduledPostsByTeam, +} from 'mattermost-redux/selectors/entities/scheduled_posts'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + import {selectLhsItem} from 'actions/views/lhs'; import {suppressRHS, unsuppressRHS} from 'actions/views/rhs'; import type {Draft} from 'selectors/drafts'; -import NoResultsIndicator from 'components/no_results_indicator'; +import DraftList from 'components/drafts/draft_list/draft_list'; +import ScheduledPostList from 'components/drafts/scheduled_post_list/scheduled_post_list'; +import Tab from 'components/tabs/tab'; +import Tabs from 'components/tabs/tabs'; import Header from 'components/widgets/header'; +import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; import {LhsItemType, LhsPage} from 'types/store/lhs'; -import DraftRow from './draft_row'; -import DraftsIllustration from './drafts_illustration'; - import './drafts.scss'; +const EMPTY_LIST: ScheduledPost[] = []; + type Props = { drafts: Draft[]; user: UserProfile; @@ -37,7 +51,17 @@ function Drafts({ user, }: Props) { const dispatch = useDispatch(); - const {formatMessage} = useIntl(); + + const history = useHistory(); + const match: match<{team: string}> = useRouteMatch(); + const isDraftsTab = useRouteMatch('/:team/drafts'); + + const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX); + + const currentTeamId = useSelector(getCurrentTeamId); + const getScheduledPostsByTeam = makeGetScheduledPostsByTeam(); + const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true)); + const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); useEffect(() => { dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts)); @@ -46,8 +70,74 @@ function Drafts({ return () => { dispatch(unsuppressRHS); }; + }, [dispatch]); + + const handleSwitchTabs = useCallback((key) => { + if (key === 0 && isScheduledPostsTab) { + history.push(`/${match.params.team}/drafts`); + } else if (key === 1 && isDraftsTab) { + history.push(`/${match.params.team}/scheduled_posts`); + } + }, [history, isDraftsTab, isScheduledPostsTab, match]); + + const scheduledPostsTabHeading = useMemo(() => { + return ( +
+ + + { + scheduledPosts?.length > 0 && + + } +
+ ); + }, [scheduledPosts?.length]); + + const draftTabHeading = useMemo(() => { + return ( +
+ + + { + drafts.length > 0 && + + } +
+ ); + }, [drafts?.length]); + + const heading = useMemo(() => { + return ( + + ); }, []); + const subtitle = useMemo(() => { + return ( + + ); + }, []); + + const activeTab = isDraftsTab ? 0 : 1; + return (
-
- {drafts.map((d) => ( - - ))} - {drafts.length === 0 && ( - - )} -
+ + { + isScheduledPostEnabled && + + + + + + + + + + } + + { + !isScheduledPostEnabled && + + }
); } diff --git a/webapp/channels/src/components/drafts/drafts_link/drafts_link.scss b/webapp/channels/src/components/drafts/drafts_link/drafts_link.scss index e228d3b988..1c54d40b99 100644 --- a/webapp/channels/src/components/drafts/drafts_link/drafts_link.scss +++ b/webapp/channels/src/components/drafts/drafts_link/drafts_link.scss @@ -19,6 +19,10 @@ .badge { background: transparent; color: var(--sidebar-text); + + .icon.icon-draft-indicator { + font-size: 14px !important; + } } &:hover { @@ -29,9 +33,38 @@ visibility: visible; } } + + #unreadMentions { + color: rgba(var(--sidebar-text-rgb), 0.64); + margin-inline: 0; + + &:not(.urgent) { + padding-inline: 0; + } + + &.scheduledPostBadge { + margin-left: 8px; + padding-block: 2px; + + &.urgent { + color: var(--sidebar-text); + } + + .unreadMentions { + position: relative; + top: -0.5px; + } + } + } + + + &.active #unreadMentions { + color: var(--sidebar-text); + } } } + // legacy .sidebar--left .SidebarDrafts { padding-bottom: 0; diff --git a/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx b/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx index ce8d4be05d..707f4f2c04 100644 --- a/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx +++ b/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx @@ -1,12 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo, useEffect} from 'react'; +import classNames from 'classnames'; +import React, {memo, useEffect, useRef} from 'react'; import {FormattedMessage} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {NavLink, useRouteMatch} from 'react-router-dom'; +import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts'; import {syncedDraftsAreAllowedAndEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import { + getScheduledPostsByTeamCount, hasScheduledPostError, isScheduledPostsEnabled, +} from 'mattermost-redux/selectors/entities/scheduled_posts'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getDrafts} from 'actions/views/drafts'; @@ -15,27 +20,68 @@ import {makeGetDraftsCount} from 'selectors/drafts'; import DraftsTourTip from 'components/drafts/drafts_link/drafts_tour_tip/drafts_tour_tip'; import ChannelMentionBadge from 'components/sidebar/sidebar_channel/channel_mention_badge'; -import './drafts_link.scss'; +import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; const getDraftsCount = makeGetDraftsCount(); +import './drafts_link.scss'; + +const scheduleIcon = ( + +); + +const pencilIcon = ( + +); + function DraftsLink() { const dispatch = useDispatch(); + const initialScheduledPostsLoaded = useRef(false); + const syncedDraftsAllowedAndEnabled = useSelector(syncedDraftsAreAllowedAndEnabled); - const count = useSelector(getDraftsCount); + const draftCount = useSelector(getDraftsCount); const teamId = useSelector(getCurrentTeamId); + const teamScheduledPostCount = useSelector((state: GlobalState) => getScheduledPostsByTeamCount(state, teamId, true)); + const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); + + const hasDrafts = draftCount > 0; + const hasScheduledPosts = teamScheduledPostCount > 0; + const itemsExist = hasDrafts || (isScheduledPostEnabled && hasScheduledPosts); + + const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, teamId)); const {url} = useRouteMatch(); const isDraftUrlMatch = useRouteMatch('/:team/drafts'); + const isScheduledPostUrlMatch = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX); + + const urlMatches = isDraftUrlMatch || isScheduledPostUrlMatch; useEffect(() => { if (syncedDraftsAllowedAndEnabled) { dispatch(getDrafts(teamId)); } - }, [teamId, syncedDraftsAllowedAndEnabled]); + }, [teamId, syncedDraftsAllowedAndEnabled, dispatch]); - if (!count && !isDraftUrlMatch) { + useEffect(() => { + const loadDMsAndGMs = !initialScheduledPostsLoaded.current; + + if (isScheduledPostEnabled) { + dispatch(fetchTeamScheduledPosts(teamId, loadDMsAndGMs)); + } + + initialScheduledPostsLoaded.current = true; + }, [dispatch, isScheduledPostEnabled, teamId]); + + if (!itemsExist && !urlMatches) { return null; } @@ -54,10 +100,7 @@ function DraftsLink() { className='SidebarLink sidebar-item' tabIndex={0} > - + {pencilIcon}
- {count > 0 && ( - - )} + { + draftCount > 0 && + + } + + { + isScheduledPostEnabled && teamScheduledPostCount > 0 && + + } diff --git a/webapp/channels/src/components/drafts/panel/panel.tsx b/webapp/channels/src/components/drafts/panel/panel.tsx index 939e6c6447..04c84e3851 100644 --- a/webapp/channels/src/components/drafts/panel/panel.tsx +++ b/webapp/channels/src/components/drafts/panel/panel.tsx @@ -12,6 +12,8 @@ type Props = { children: ({hover}: {hover: boolean}) => React.ReactNode; onClick: () => void; hasError: boolean; + innerRef?: React.Ref; + isHighlighted?: boolean; }; const isEligibleForClick = makeIsEligibleForClick('.hljs, code'); @@ -20,6 +22,8 @@ function Panel({ children, onClick, hasError, + innerRef, + isHighlighted, }: Props) { const [hover, setHover] = useState(false); @@ -43,12 +47,14 @@ function Panel({ 'Panel', { draftError: hasError, + highlighted: isHighlighted, }, )} onMouseOver={handleMouseOver} onClick={handleOnClick} onMouseLeave={handleMouseLeave} role='button' + ref={innerRef} > {children({hover})} diff --git a/webapp/channels/src/components/drafts/panel/panel_body.tsx b/webapp/channels/src/components/drafts/panel/panel_body.tsx index 1cf50db996..d2330f20bc 100644 --- a/webapp/channels/src/components/drafts/panel/panel_body.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_body.tsx @@ -21,7 +21,7 @@ import type {PostDraft} from 'types/store/draft'; import './panel_body.scss'; type Props = { - channelId: string; + channelId?: string; displayName: string; fileInfos: PostDraft['fileInfos']; message: string; diff --git a/webapp/channels/src/components/drafts/panel/panel_header.test.tsx b/webapp/channels/src/components/drafts/panel/panel_header.test.tsx index 0dc1c2a6d4..941805891e 100644 --- a/webapp/channels/src/components/drafts/panel/panel_header.test.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_header.test.tsx @@ -9,12 +9,14 @@ import WithTooltip from 'components/with_tooltip'; import PanelHeader from './panel_header'; describe('components/drafts/panel/panel_header', () => { - const baseProps = { + const baseProps: React.ComponentProps = { + kind: 'draft' as const, actions:
{'actions'}
, hover: false, timestamp: 12345, remote: false, title:
{'title'}
, + error: undefined, }; it('should match snapshot', () => { diff --git a/webapp/channels/src/components/drafts/panel/panel_header.tsx b/webapp/channels/src/components/drafts/panel/panel_header.tsx index 3ee491e9b1..027f87b331 100644 --- a/webapp/channels/src/components/drafts/panel/panel_header.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_header.tsx @@ -2,17 +2,18 @@ // See LICENSE.txt for license information. import cn from 'classnames'; -import React from 'react'; +import React, {useMemo} from 'react'; import type {ComponentProps} from 'react'; import {FormattedMessage} from 'react-intl'; import {SyncIcon} from '@mattermost/compass-icons/components'; -import Timestamp from 'components/timestamp'; +import Timestamp, {RelativeRanges} from 'components/timestamp'; import Tag from 'components/widgets/tag/tag'; import WithTooltip from 'components/with_tooltip'; import './panel_header.scss'; +import {isToday} from 'utils/datetime'; const TIMESTAMP_PROPS: Partial> = { day: 'numeric', @@ -21,7 +22,16 @@ const TIMESTAMP_PROPS: Partial> = { units: ['now', 'minute', 'hour', 'day', 'week', 'month', 'year'], }; +export const SCHEDULED_POST_TIME_RANGES = [ + RelativeRanges.TODAY_TITLE_CASE, + RelativeRanges.YESTERDAY_TITLE_CASE, + RelativeRanges.TOMORROW_TITLE_CASE, +]; + +export const scheduledPostTimeFormat: ComponentProps['useTime'] = (_, {hour, minute}) => ({hour, minute}); + type Props = { + kind: 'draft' | 'scheduledPost'; actions: React.ReactNode; hover: boolean; timestamp: number; @@ -31,6 +41,7 @@ type Props = { }; function PanelHeader({ + kind, actions, hover, timestamp, @@ -38,6 +49,8 @@ function PanelHeader({ title, error, }: Props) { + const timestampDateObject = useMemo(() => new Date(timestamp), [timestamp]); + return (
{title}
@@ -63,14 +76,37 @@ function PanelHeader({
)}
- {Boolean(timestamp) && ( - - )} + { + Boolean(timestamp) && kind === 'draft' && ( + + ) + } + + { + Boolean(timestamp) && kind === 'scheduledPost' && ( + + ), + isTodayOrTomorrow: isToday(timestampDateObject), + }} + /> + ) + }
- {!error && ( + + {kind === 'draft' && !error && ( + ); + + if (type === 'thread') { + title = ( + + ); + } else { + title = ( + + ); + } + + return title; +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration.tsx new file mode 100644 index 0000000000..a23047c574 --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +export default ( + + + + + + + + + + + + + + + + + + + + +); diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx new file mode 100644 index 0000000000..fd10d7a737 --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx @@ -0,0 +1,110 @@ +// 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 {ScheduledPost} from '@mattermost/types/schedule_post'; +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import {fetchMissingChannels} from 'mattermost-redux/actions/channels'; +import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import AlertBanner from 'components/alert_banner'; +import NoScheduledPostsIllustration from 'components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration'; +import NoResultsIndicator from 'components/no_results_indicator'; + +import {useQuery} from 'utils/http_utils'; + +import type {GlobalState} from 'types/store'; + +import DraftRow from '../draft_row'; + +import './style.scss'; + +type Props = { + scheduledPosts: ScheduledPost[]; + user: UserProfile; + displayName: string; + status: UserStatus['status']; +} + +export default function ScheduledPostList({ + scheduledPosts, + user, + displayName, + status, +}: Props) { + const {formatMessage} = useIntl(); + + const currentTeamId = useSelector(getCurrentTeamId); + const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId)); + + const query = useQuery(); + const targetId = query.get('target_id'); + const targetScheduledPostId = useRef(); + + const dispatch = useDispatch(); + useEffect(() => { + dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id))); + }, []); + + return ( +
+ { + scheduledPostsHasError && + + } + /> + } + + { + scheduledPosts.map((scheduledPost) => { + // find the first scheduled posst with the target + const scrollIntoView = !targetScheduledPostId.current && (scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId); + if (scrollIntoView) { + // if found, save the scheduled post's ID + targetScheduledPostId.current = scheduledPost.id; + } + + return ( + + ); + }) + } + + { + scheduledPosts.length === 0 && ( + + ) + } +
+ ); +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/style.scss b/webapp/channels/src/components/drafts/scheduled_post_list/style.scss new file mode 100644 index 0000000000..c57bdbf154 --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/style.scss @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.ScheduledPostList { + position: absolute; + width: 100%; + height: 100%; + padding: 24px; + overflow-y: auto; + + .scheduledPostListErrorIndicator { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 16px; + font-weight: bold; + padding-block: 10px; + } + + .AlertBanner { + &__body { + color: rgba(var(--center-channel-color-rgb), 0.75); + } + } + + @keyframes borderColorAnimation { + 0% { + background: rgba(var(--mention-highlight-bg-rgb), 0.5); + } + 100% { + background: initial; + } + } + + .Panel.highlighted { + animation: borderColorAnimation 10s; + } + + +} diff --git a/webapp/channels/src/components/drafts/utils.ts b/webapp/channels/src/components/drafts/utils.ts new file mode 100644 index 0000000000..81cc2c3894 --- /dev/null +++ b/webapp/channels/src/components/drafts/utils.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {IntlShape} from 'react-intl'; +import {defineMessages} from 'react-intl'; + +import type {ScheduledPostErrorCode} from '@mattermost/types/schedule_post'; + +const errorCodeToErrorMessage = defineMessages({ + unknown: { + id: 'scheduled_post.error_code.unknown_error', + defaultMessage: 'Unknown Error', + }, + channel_archived: { + id: 'scheduled_post.error_code.channel_archived', + defaultMessage: 'Channel Archived', + }, + channel_not_found: { + id: 'scheduled_post.error_code.channel_removed', + defaultMessage: 'Channel Removed', + }, + user_missing: { + id: 'scheduled_post.error_code.user_missing', + defaultMessage: 'User Deleted', + }, + user_deleted: { + id: 'scheduled_post.error_code.user_deleted', + defaultMessage: 'User Deleted', + }, + no_channel_permission: { + id: 'scheduled_post.error_code.no_channel_permission', + defaultMessage: 'Missing Permission', + }, + no_channel_member: { + id: 'scheduled_post.error_code.no_channel_member', + defaultMessage: 'Not In Channel', + }, + thread_deleted: { + id: 'scheduled_post.error_code.thread_deleted', + defaultMessage: 'Thread Deleted', + }, + unable_to_send: { + id: 'scheduled_post.error_code.unable_to_send', + defaultMessage: 'Unable to Send', + }, + invalid_post: { + id: 'scheduled_post.error_code.invalid_post', + defaultMessage: 'Invalid Post', + }, +}); + +export function getErrorStringFromCode(intl: IntlShape, errorCode: ScheduledPostErrorCode = 'unknown') { + return intl.formatMessage(errorCodeToErrorMessage[errorCode]).toUpperCase(); +} diff --git a/webapp/channels/src/components/edit_post/edit_post.tsx b/webapp/channels/src/components/edit_post/edit_post.tsx index d27e1e7a8e..4cb722dc65 100644 --- a/webapp/channels/src/components/edit_post/edit_post.tsx +++ b/webapp/channels/src/components/edit_post/edit_post.tsx @@ -4,23 +4,32 @@ import classNames from 'classnames'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; import {EmoticonPlusOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components'; import type {Emoji} from '@mattermost/types/emojis'; import type {Post} from '@mattermost/types/posts'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; +import {scheduledPostToPost} from '@mattermost/types/schedule_post'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; +import {openModal} from 'actions/views/modals'; +import {getConnectionId} from 'selectors/general'; + import DeletePostModal from 'components/delete_post_modal'; +import DeleteScheduledPostModal + from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal'; import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; import Textbox from 'components/textbox'; import type {TextboxClass, TextboxElement} from 'components/textbox'; import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants'; import * as Keyboard from 'utils/keyboard'; -import {applyMarkdown} from 'utils/markdown/apply_markdown'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {applyMarkdown} from 'utils/markdown/apply_markdown'; import { formatGithubCodePaste, formatMarkdownMessage, @@ -32,24 +41,21 @@ import {postMessageOnKeyPress, splitMessageBasedOnCaretPosition} from 'utils/pos import {allAtMentions} from 'utils/text_formatting'; import * as Utils from 'utils/utils'; -import type {ModalData} from 'types/actions'; +import type {GlobalState} from 'types/store'; import type {PostDraft} from 'types/store/draft'; import EditPostFooter from './edit_post_footer'; -type DialogProps = { - post?: Post; - isRHS?: boolean; -}; +import './style.scss'; export type Actions = { addMessageIntoHistory: (message: string) => void; editPost: (input: Partial) => Promise; setDraft: (name: string, value: PostDraft | null) => void; unsetEditingPost: () => void; - openModal: (input: ModalData) => void; scrollPostListToBottom: () => void; runMessageWillBeUpdatedHooks: (newPost: Partial, oldPost: Post) => Promise; + updateScheduledPost: (scheduledPost: ScheduledPost, connectionId: string) => Promise; } export type Props = { @@ -77,6 +83,10 @@ export type Props = { isRHSOpened: boolean; isEditHistoryShowing: boolean; actions: Actions; + scheduledPost?: ScheduledPost; + afterSave?: () => void; + onCancel?: () => void; + onDeleteScheduledPost?: () => Promise<{error?: string}>; }; export type State = { @@ -95,9 +105,14 @@ const {KeyCodes} = Constants; const TOP_OFFSET = 0; const RIGHT_OFFSET = 10; -const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, ...rest}: Props): JSX.Element | null => { +const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => { + const connectionId = useSelector(getConnectionId); + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); + + const dispatch = useDispatch(); + const [editText, setEditText] = useState( - draft.message || editingPost?.post?.message_source || editingPost?.post?.message || '', + draft.message || editingPost?.post?.message_source || editingPost?.post?.message || scheduledPost?.message || '', ); const [selectionRange, setSelectionRange] = useState({start: editText.length, end: editText.length}); const caretPosition = useRef(editText.length); @@ -117,7 +132,8 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, const draftRef = useRef(draft); const saveDraftFrame = useRef(); - const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${editingPost.postId}`; + const id = scheduledPost ? scheduledPost.id : editingPost.postId; + const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${id}`; const {formatMessage} = useIntl(); @@ -141,9 +157,11 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, actions.setDraft(draftStorageId, draftRef.current); }, Constants.SAVE_DRAFT_TIMEOUT); - const isMentions = allAtMentions(editText).length > 0; - setShowMentionHelper(isMentions); - }, [actions, draftStorageId, editText]); + if (!scheduledPost) { + const isMentions = allAtMentions(editText).length > 0; + setShowMentionHelper(isMentions); + } + }, [actions, draftStorageId, editText, scheduledPost]); useEffect(() => { const focusTextBox = () => textboxRef?.current?.focus(); @@ -247,6 +265,11 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, }; const handleEdit = async () => { + if (scheduledPost) { + await handleEditScheduledPost(); + return; + } + if (!editingPost.post || isSaveDisabled()) { return; } @@ -291,15 +314,98 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, }, }; - actions.openModal(deletePostModalData); + dispatch(openModal(deletePostModalData)); return; } await actions.editPost(updatedPost as Post); handleAutomatedRefocusAndExit(); + afterSave?.(); }; + const handleCancel = useCallback(() => { + onCancel?.(); + handleAutomatedRefocusAndExit(); + }, [onCancel, handleAutomatedRefocusAndExit]); + + const handleEditScheduledPost = useCallback(async () => { + if (!scheduledPost || isSaveDisabled() || !channel || !onDeleteScheduledPost) { + return; + } + + const post = scheduledPostToPost(scheduledPost); + + let updatedPost = { + message: editText, + id: scheduledPost.id, + channel_id: scheduledPost?.channel_id, + }; + + const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, post); + if (hookResult.error && hookResult.error.message) { + setPostError(<>{hookResult.error.message}); + return; + } + + updatedPost = hookResult.data; + + if (postError) { + setErrorClass('animation--highlight'); + setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT); + return; + } + + if (updatedPost.message === post.message) { + handleAutomatedRefocusAndExit(); + return; + } + + const hasAttachment = Boolean( + scheduledPost.file_ids && scheduledPost.file_ids.length > 0, + ); + if (updatedPost.message.trim().length === 0 && !hasAttachment) { + handleRefocusAndExit(null); + + const deleteScheduledPostModalData = { + modalId: ModalIdentifiers.DELETE_DRAFT, + dialogType: DeleteScheduledPostModal, + dialogProps: { + channelDisplayName: channel.display_name, + onConfirm: onDeleteScheduledPost, + }, + }; + + dispatch(openModal(deleteScheduledPostModalData)); + return; + } + + const updatedScheduledPost = { + ...scheduledPost, + message: updatedPost.message, + }; + + const response = await actions.updateScheduledPost(updatedScheduledPost, connectionId); + if (response.error) { + setPostError(response.error.message); + } else { + handleAutomatedRefocusAndExit(); + afterSave?.(); + } + }, [ + actions, + connectionId, + editText, + handleAutomatedRefocusAndExit, + handleRefocusAndExit, + isSaveDisabled, + postError, + scheduledPost, + afterSave, + channel, + onDeleteScheduledPost, + ]); + const handleEditKeyPress = (e: React.KeyboardEvent) => { const {ctrlSend, codeBlockOnCtrlEnter} = rest; const inputBox = textboxRef.current?.getInputBox(); @@ -348,6 +454,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, } else if (ctrlEnterKeyCombo) { handleEdit(); } else if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) { + onCancel?.(); handleAutomatedRefocusAndExit(); } else if (ctrlAltCombo && markdownLinkKey) { applyHotkeyMarkdown({ @@ -550,7 +657,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, } {postError && (
diff --git a/webapp/channels/src/components/edit_post/index.ts b/webapp/channels/src/components/edit_post/index.ts index b473c5ba8b..9c85116534 100644 --- a/webapp/channels/src/components/edit_post/index.ts +++ b/webapp/channels/src/components/edit_post/index.ts @@ -5,7 +5,10 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; + import {addMessageIntoHistory} from 'mattermost-redux/actions/posts'; +import {updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts'; import {Preferences, Permissions} from 'mattermost-redux/constants'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; @@ -18,7 +21,6 @@ import {runMessageWillBeUpdatedHooks} from 'actions/hooks'; import {unsetEditingPost} from 'actions/post_actions'; import {setGlobalItem} from 'actions/storage'; import {scrollPostListToBottom} from 'actions/views/channel'; -import {openModal} from 'actions/views/modals'; import {editPost} from 'actions/views/posts'; import {getEditingPost} from 'selectors/posts'; import {getIsRhsOpen, getPostDraft, getRhsState} from 'selectors/rhs'; @@ -29,13 +31,29 @@ import type {GlobalState} from 'types/store'; import EditPost from './edit_post'; -function mapStateToProps(state: GlobalState) { +type Props = { + scheduledPost?: ScheduledPost; +} + +function mapStateToProps(state: GlobalState, props: Props) { const config = getConfig(state); - const editingPost = getEditingPost(state); + + let editingPost; + let channelId: string; + let draft; + + if (props.scheduledPost) { + editingPost = {post: null}; + channelId = props.scheduledPost.channel_id; + draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, props.scheduledPost.id); + } else { + editingPost = getEditingPost(state); + channelId = editingPost.post.channel_id; + draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId); + } + const currentUserId = getCurrentUserId(state); - const channelId = editingPost.post.channel_id; const teamId = getCurrentTeamId(state); - const draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId); const isAuthor = editingPost?.post?.user_id === currentUserId; const deletePermission = isAuthor ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS; @@ -59,6 +77,7 @@ function mapStateToProps(state: GlobalState) { useChannelMentions, isRHSOpened: getIsRhsOpen(state), isEditHistoryShowing: getRhsState(state) === RHSStates.EDIT_HISTORY, + scheduledPost: props.scheduledPost, }; } @@ -70,8 +89,8 @@ function mapDispatchToProps(dispatch: Dispatch) { editPost, setDraft: setGlobalItem, unsetEditingPost, - openModal, runMessageWillBeUpdatedHooks, + updateScheduledPost, }, dispatch), }; } diff --git a/webapp/channels/src/components/edit_post/style.scss b/webapp/channels/src/components/edit_post/style.scss new file mode 100644 index 0000000000..24a5f8ba48 --- /dev/null +++ b/webapp/channels/src/components/edit_post/style.scss @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.post--editing__wrapper { + position: relative; + + textarea, #edit_textbox_placeholder { + padding: 13px 16px 12px 16px; + padding-right: 50px; + } + + .edit-post-footer { + &.has-error { + color: rgb(var(--dnd-indicator-rgb)); + } + } +} diff --git a/webapp/channels/src/components/menu/menu.tsx b/webapp/channels/src/components/menu/menu.tsx index 31b8467cf5..625bb44325 100644 --- a/webapp/channels/src/components/menu/menu.tsx +++ b/webapp/channels/src/components/menu/menu.tsx @@ -73,6 +73,9 @@ interface Props { menuButtonTooltip?: MenuButtonTooltipProps; menu: MenuProps; children: ReactNode[]; + + // Use MUI Anchor Playgroup to try various anchorOrigin + // and transformOrigin values - https://mui.com/material-ui/react-popover/#anchor-playground anchorOrigin?: { vertical: VerticalOrigin; horizontal: HorizontalOrigin; @@ -81,6 +84,7 @@ interface Props { vertical: VerticalOrigin; horizontal: HorizontalOrigin; }; + hideTooltipWhenDisabled?: boolean; } /** @@ -205,7 +209,7 @@ export function Menu(props: Props) { id={props.menuButtonTooltip.id} title={props.menuButtonTooltip.text} placement={props?.menuButtonTooltip?.placement ?? 'top'} - disabled={isMenuOpen} + disabled={props.hideTooltipWhenDisabled ? props.menuButton.disabled || isMenuOpen : isMenuOpen} > {triggerElement} diff --git a/webapp/channels/src/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap b/webapp/channels/src/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap index efa3565cbd..2ada7c2e66 100644 --- a/webapp/channels/src/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap +++ b/webapp/channels/src/components/plugin_marketplace/__snapshots__/marketplace_modal.test.tsx.snap @@ -42,14 +42,18 @@ exports[`components/marketplace/ doesn't show web marketplace banner in FeatureF closeLabel="Close" >
-

- App Marketplace -

+

+ App Marketplace +

+
-

- App Marketplace -

+

+ App Marketplace +

+
-

- App Marketplace -

+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> - - } - inputSize="large" - name="searchMarketplaceTextbox" - onChange={[Function]} - onClear={[Function]} - placeholder="Search marketplace" - type="text" - useLegend={false} - value="" - />
-

- App Marketplace -

+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> - - } - inputSize="large" - name="searchMarketplaceTextbox" - onChange={[Function]} - onClear={[Function]} - placeholder="Search marketplace" - type="text" - useLegend={false} - value="" - />
-

- App Marketplace -

+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> - - } - inputSize="large" - name="searchMarketplaceTextbox" - onChange={[Function]} - onClear={[Function]} - placeholder="Search marketplace" - type="text" - useLegend={false} - value="" - />
-

- App Marketplace -

+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> - - } - inputSize="large" - name="searchMarketplaceTextbox" - onChange={[Function]} - onClear={[Function]} - placeholder="Search marketplace" - type="text" - useLegend={false} - value="" - />
-

- App Marketplace -

+

+ App Marketplace +

+
+ + } + inputSize="large" + name="searchMarketplaceTextbox" + onChange={[Function]} + onClear={[Function]} + placeholder="Search marketplace" + type="text" + useLegend={false} + value="" + /> - - } - inputSize="large" - name="searchMarketplaceTextbox" - onChange={[Function]} - onClear={[Function]} - placeholder="Search marketplace" - type="text" - useLegend={false} - value="" - /> void; @@ -25,63 +22,27 @@ type Props = PropsFromRedux & { }; function PostReminderCustomTimePicker({userId, timezone, onExited, postId, actions}: Props) { + const {formatMessage} = useIntl(); + const ariaLabel = formatMessage({id: 'post_reminder_custom_time_picker_modal.defaultMsg', defaultMessage: 'Set a reminder'}); + const header = formatMessage({id: 'post_reminder.custom_time_picker_modal.header', defaultMessage: 'Set a reminder'}); + const confirmButtonText = formatMessage({id: 'post_reminder.custom_time_picker_modal.submit_button', defaultMessage: 'Set reminder'}); + const currentTime = getCurrentMomentForTimezone(timezone); const initialReminderTime = getRoundedTime(currentTime); - const [customReminderTime, setCustomReminderTime] = useState(initialReminderTime); - - const handleConfirm = useCallback(() => { - actions.addPostReminder(userId, postId, toUTCUnix(customReminderTime.toDate())); - }, [customReminderTime]); - - const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); - - const {formatMessage} = useIntl(); - - useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isDatePickerOpen) { - onExited(); - } - } - - document.addEventListener('keydown', handleKeyDown); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isDatePickerOpen]); + const handleConfirm = useCallback((dateTime: Moment) => { + actions.addPostReminder(userId, postId, toUTCUnix(dateTime.toDate())); + }, [actions, postId, userId]); return ( - - )} - confirmButtonText={( - - )} - handleConfirm={handleConfirm} - handleEnterKeyPress={handleConfirm} - className={'post-reminder-modal'} - compassDesign={true} - keyboardEscape={false} - > - - + ariaLabel={ariaLabel} + header={header} + initialTime={initialReminderTime} + onConfirm={handleConfirm} + confirmButtonText={confirmButtonText} + /> ); } diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index d8528e0498..173cc4171e 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -32,7 +32,7 @@ import SidebarMobileRightMenu from 'components/sidebar_mobile_right_menu'; import webSocketClient from 'client/web_websocket_client'; import {initializePlugins} from 'plugins'; import A11yController from 'utils/a11y_controller'; -import {PageLoadContext} from 'utils/constants'; +import {PageLoadContext, SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {EmojiIndicesByAlias} from 'utils/emoji'; import {TEAM_NAME_PATH_PATTERN} from 'utils/path'; @@ -580,7 +580,7 @@ export default class Root extends React.PureComponent { export function doesRouteBelongToTeamControllerRoutes(pathname: RouteComponentProps['location']['pathname']): boolean { // Note: we have specifically added admin_console to the negative lookahead as admin_console can have integrations as subpaths (admin_console/integrations/bot_accounts) // and we don't want to treat those as team controller routes. - const TEAM_CONTROLLER_PATH_PATTERN = /^\/(?!admin_console)([a-z0-9\-_]+)\/(channels|messages|threads|drafts|integrations|emoji)(\/.*)?$/; + const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`); return TEAM_CONTROLLER_PATH_PATTERN.test(pathname); } diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx index 12cf456e90..f255584a4c 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/channel_mention_badge.tsx @@ -7,16 +7,21 @@ import React from 'react'; type Props = { unreadMentions: number; hasUrgent?: boolean; + icon?: React.ReactNode; + className?: string; }; -export default function ChannelMentionBadge({unreadMentions, hasUrgent}: Props) { +export default function ChannelMentionBadge({unreadMentions, hasUrgent, icon, className}: Props) { if (unreadMentions > 0) { return ( - {unreadMentions} + {icon} + + {unreadMentions} + ); } diff --git a/webapp/channels/src/components/tabs/tab/index.tsx b/webapp/channels/src/components/tabs/tab/index.tsx new file mode 100644 index 0000000000..96f25b1712 --- /dev/null +++ b/webapp/channels/src/components/tabs/tab/index.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Tab as ReactBootstrapTab} from 'react-bootstrap'; + +type Props = { + children?: React.ReactNode; + eventKey?: any; + title?: React.ReactNode | undefined; + unmountOnExit?: boolean | undefined; + tabClassName?: string | undefined; +} + +export default function Tab({children, title, unmountOnExit, tabClassName, eventKey}: Props) { + return ( + + {children} + + ); +} diff --git a/webapp/channels/src/components/tabs/tabs/index.tsx b/webapp/channels/src/components/tabs/tabs/index.tsx new file mode 100644 index 0000000000..49fa96d99d --- /dev/null +++ b/webapp/channels/src/components/tabs/tabs/index.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 {Tabs as ReactBootstrapTabs} from 'react-bootstrap'; +import type {SelectCallback} from 'react-bootstrap'; + +import './style.scss'; + +type Props = { + children?: React.ReactNode; + id?: string; + activeKey?: any; + mountOnEnter?: boolean; + unmountOnExit?: boolean; + onSelect?: SelectCallback; + className?: string; +} + +export default function Tabs({ + children, + id, + activeKey, + unmountOnExit, + onSelect, + className, + mountOnEnter, +}: Props) { + return ( + + {children} + + ); +} diff --git a/webapp/channels/src/components/tabs/tabs/style.scss b/webapp/channels/src/components/tabs/tabs/style.scss new file mode 100644 index 0000000000..2804f2edb5 --- /dev/null +++ b/webapp/channels/src/components/tabs/tabs/style.scss @@ -0,0 +1,49 @@ +.tabs { + display: flex; + width: 100%; + flex-direction: column; + margin: 0; + + .nav-tabs { + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + margin: 0; + padding-inline: 12px; + + li { + margin-right: 0; + + a { + padding: 13px 10px; + border: none; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 14px; + font-weight: 600; + line-height: 20px; + transition: all 0.15s ease; + + &:hover, + &:active, + &:focus, + &:focus-within { + border: none; + border-radius: none; + background: transparent; + color: var(--center-channel-color); + } + } + + &.active { + border-bottom: 2px solid var(--denim-button-bg); + + a { + color: var(--denim-button-bg); + } + } + + &:not(:first-child) { + margin-left: 8px; + } + } + } +} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 65a5dbd4af..7eaa0d0a1c 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1993,6 +1993,8 @@ "admin.posts.persistentNotificationsMaxRecipients.title": "Maximum number of recipients for persistent notifications", "admin.posts.postPriority.desc": "When enabled, users can configure a visual indicator to communicate messages that are important or urgent. Learn more about message priority in our documentation.", "admin.posts.postPriority.title": "Message Priority", + "admin.posts.scheduledPosts.description": "When enabled, users can schedule and send messages in the future.", + "admin.posts.scheduledPosts.title": "Scheduled Posts", "admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators and the System Roles with read/write access to Compliance, Billing, or User Management.", "admin.privacy.showEmailTitle": "Show Email Address: ", "admin.privacy.showFullNameDescription": "When false, hides the full name of members from everyone except System Administrators. Username is shown in place of full name.", @@ -3448,6 +3450,13 @@ "create_group_memberships_modal.create": "Yes", "create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.", "create_group_memberships_modal.title": "Re-add {username} to teams and channels", + "create_post_button.option.schedule_message": "Schedule message", + "create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time", + "create_post_button.option.schedule_message.options.header": "Schedule message", + "create_post_button.option.schedule_message.options.monday": "Monday at {9amTime}", + "create_post_button.option.schedule_message.options.next_monday": "Next Monday at {9amTime}", + "create_post_button.option.schedule_message.options.tomorrow": "Tomorrow at {9amTime}", + "create_post_button.option.send_now": "Send Now", "create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.", "create_post.error_message": "Your message is too long. Character count: {length}/{limit}", "create_post.file_limit_sticky_banner.admin_message": "New uploads will automatically archive older files. To view them again, you can delete older files or upgrade to a paid plan.", @@ -3455,7 +3464,6 @@ "create_post.file_limit_sticky_banner.non_admin_message": "New uploads will automatically archive older files. To view them again, notify your admin to upgrade to a paid plan.", "create_post.file_limit_sticky_banner.snooze_tooltip": "Snooze for {snoozeDays} days", "create_post.fileProcessing": "Processing...", - "create_post.icon": "Create a post", "create_post.prewritten.custom": "Custom message...", "create_post.prewritten.tip.dm_hello": "Oh hello", "create_post.prewritten.tip.dm_hello_message": ":v: Oh hello", @@ -3470,7 +3478,6 @@ "create_post.prewritten.tip.team_hi": "Hi team!", "create_post.prewritten.tip.team_hi_message": ":wave: Hi team!", "create_post.read_only": "This channel is read-only. Only members with permission can post here.", - "create_post.send_message": "Send a message", "create_post.shortcutsNotSupported": "Keyboard shortcuts are not supported on your device.", "create_post.write": "Write to {channelDisplayName}", "create_team.createTeamRestricted.message": "Your workspace plan has reached the limit on the number of teams. Create unlimited teams with a free 30-day trial. Contact your System Administrator.", @@ -3575,6 +3582,7 @@ "dnd_custom_time_picker_modal.time": "Time", "drafts.actions.delete": "Delete draft", "drafts.actions.edit": "Edit draft", + "drafts.actions.scheduled": "Schedule draft", "drafts.actions.send": "Send draft", "drafts.confirm.delete.button": "Yes, delete", "drafts.confirm.delete.text": "Are you sure you want to delete this draft to {displayName}?", @@ -4842,6 +4850,37 @@ "saveChangesPanel.save": "Save", "saveChangesPanel.saved": "Settings saved", "saveChangesPanel.tryAgain": "Try again", + "schedule_post.custom_time_modal.cancel_button_text": "Cancel", + "schedule_post.custom_time_modal.confirm_button_text": "Schedule", + "schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}", + "schedule_post.custom_time_modal.title": "Schedule message", + "Schedule_post.empty_state.subtitle": "Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.", + "Schedule_post.empty_state.title": "No scheduled drafts at the moment", + "schedule_post.tab.heading": "Scheduled", + "scheduled_post.action.delete": "Delete scheduled post", + "scheduled_post.action.edit": "Edit scheduled post", + "scheduled_post.action.reschedule": "Reschedule post", + "scheduled_post.action.send_now": "Send now", + "scheduled_post.channel_indicator.link_to_scheduled_posts.text": "See all scheduled messages", + "scheduled_post.channel_indicator.multiple": "You have {count} scheduled messages.", + "scheduled_post.channel_indicator.single": "Message scheduled for {dateTime}.", + "scheduled_post.channel_indicator.with_other_user_late_time": "You have {count, plural, =1 {one} other {#}} scheduled {count, plural, =1 {message} other {messages}}.", + "scheduled_post.delete_modal.body": "Are you sure you want to delete this scheduled post to {displayName}?", + "scheduled_post.delete_modal.title": "Delete scheduled post", + "scheduled_post.error_code.channel_archived": "Channel Archived", + "scheduled_post.error_code.channel_removed": "Channel Removed", + "scheduled_post.error_code.invalid_post": "Invalid Post", + "scheduled_post.error_code.no_channel_member": "Not In Channel", + "scheduled_post.error_code.no_channel_permission": "Missing Permission", + "scheduled_post.error_code.thread_deleted": "Thread Deleted", + "scheduled_post.error_code.unable_to_send": "Unable to Send", + "scheduled_post.error_code.unknown_error": "Unknown Error", + "scheduled_post.error_code.user_deleted": "User Deleted", + "scheduled_post.error_code.user_missing": "User Deleted", + "scheduled_post.panel.error_indicator.message": "One of your scheduled drafts cannot be sent.", + "scheduled_post.panel.header.time": "Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}", + "scheduled_posts.row_title_channel.placeholder": "In: {icon} No Destination", + "scheduled_posts.row_title_thread.placeholder": "Thread to: {icon} No Destination", "search_bar.channels": "Channels", "search_bar.clear": "Clear", "search_bar.file_types": "File types", @@ -4962,6 +5001,7 @@ "shortcuts.files.upload.mac": "Upload files:\t⌘|U", "shortcuts.generic.alt": "Alt", "shortcuts.generic.ctrl": "Ctrl", + "shortcuts.generic.enter": "Enter", "shortcuts.generic.shift": "Shift", "shortcuts.header": "Keyboard shortcuts\tCtrl|/", "shortcuts.header.mac": "Keyboard shortcuts\t⌘|/", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts index b218110476..1fd4d5ff4a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts @@ -24,6 +24,7 @@ import PostTypes from './posts'; import PreferenceTypes from './preferences'; import RoleTypes from './roles'; import SchemeTypes from './schemes'; +import ScheduledPostTypes from './scheudled_posts'; import SearchTypes from './search'; import TeamTypes from './teams'; import ThreadTypes from './threads'; @@ -57,4 +58,5 @@ export { DraftTypes, PlaybookType, ChannelBookmarkTypes, + ScheduledPostTypes, }; diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/scheudled_posts.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/scheudled_posts.ts new file mode 100644 index 0000000000..5999b0f316 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/scheudled_posts.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from 'key-mirror'; + +export default keyMirror({ + SCHEDULED_POSTS_RECEIVED: null, + SINGLE_SCHEDULED_POST_RECEIVED: null, + SCHEDULED_POST_UPDATED: null, + SCHEDULED_POST_DELETED: null, +}); 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 9b9ea3733f..f943a1172f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -35,6 +35,7 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import type {GetStateFunc, ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; import {getChannelByName} from 'mattermost-redux/utils/channel_utils'; +import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader'; import {addChannelToInitialCategory, addChannelToCategory} from './channel_categories'; import {logError} from './errors'; @@ -1426,6 +1427,32 @@ export function getChannelMemberCountsByGroup(channelId: string) { }); } +export function fetchMissingChannels(channelIDs: string[]): ActionFuncAsync> { + return async (dispatch, getState, {loaders}: any) => { + if (!loaders.missingChannelLoader) { + loaders.missingChannelLoader = new DelayedDataLoader({ + fetchBatch: (channelIDs) => { + return channelIDs.length ? dispatch(getChannel(channelIDs[0])) : Promise.resolve(); + }, + maxBatchSize: 1, + wait: 100, + }); + } + + const state = getState(); + const missingChannelIDs = channelIDs.filter((channelId) => !getChannelSelector(state, channelId)); + + if (missingChannelIDs.length > 0) { + const loader = loaders.missingChannelLoader as DelayedDataLoader; + loader.queue(missingChannelIDs); + } + + return { + data: missingChannelIDs, + }; + }; +} + export default { selectChannel, createChannel, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index 4133024b9a..eb91814103 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -172,6 +172,7 @@ export function getPost(postId: string): ActionFuncAsync { export type CreatePostReturnType = { created?: boolean; pending?: string; + error?: string; } export function createPost( diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/scheduled_posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/scheduled_posts.ts new file mode 100644 index 0000000000..14a7bdf464 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/scheduled_posts.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; + +import {ScheduledPostTypes} from 'mattermost-redux/action_types'; +import {logError} from 'mattermost-redux/actions/errors'; +import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {Client4} from 'mattermost-redux/client'; +import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; + +export function createSchedulePost(schedulePost: ScheduledPost, teamId: string, connectionId: string) { + return async (dispatch: DispatchFunc) => { + try { + const createdPost = await Client4.createScheduledPost(schedulePost, connectionId); + + dispatch({ + type: ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED, + data: { + scheduledPost: createdPost.data, + teamId, + }, + }); + + return {data: createdPost}; + } catch (error) { + return { + error, + }; + } + }; +} + +export function fetchTeamScheduledPosts(teamId: string, includeDirectChannels: boolean) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let scheduledPosts; + + try { + scheduledPosts = await Client4.getScheduledPosts(teamId, includeDirectChannels); + dispatch({ + type: ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED, + data: { + scheduledPostsByTeamId: scheduledPosts.data, + }, + }); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + return {data: scheduledPosts}; + }; +} + +export function updateScheduledPost(scheduledPost: ScheduledPost, connectionId: string) { + return async (dispatch: DispatchFunc) => { + try { + const updatedScheduledPost = await Client4.updateScheduledPost(scheduledPost, connectionId); + + dispatch({ + type: ScheduledPostTypes.SCHEDULED_POST_UPDATED, + data: { + scheduledPost: updatedScheduledPost.data, + }, + }); + + return {data: updatedScheduledPost}; + } catch (error) { + return { + error, + }; + } + }; +} + +export function deleteScheduledPost(scheduledPostId: string, connectionId: string) { + return async (dispatch: DispatchFunc) => { + try { + const deletedScheduledPost = await Client4.deleteScheduledPost(scheduledPostId, connectionId); + + dispatch({ + type: ScheduledPostTypes.SCHEDULED_POST_DELETED, + data: { + scheduledPost: deletedScheduledPost.data, + }, + }); + + return {data: deletedScheduledPost}; + } catch (error) { + return { + error, + }; + } + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts index 68f75ae494..eec5e9a92b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts @@ -21,6 +21,7 @@ import limits from './limits'; import posts from './posts'; import preferences from './preferences'; import roles from './roles'; +import scheduledPosts from './scheduled_posts'; import schemes from './schemes'; import search from './search'; import teams from './teams'; @@ -55,4 +56,5 @@ export default combineReducers({ usage, hostedCustomer, channelBookmarks, + scheduledPosts, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/scheduled_posts.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/scheduled_posts.ts new file mode 100644 index 0000000000..2295a0e694 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/scheduled_posts.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AnyAction} from 'redux'; +import {combineReducers} from 'redux'; + +import type {ScheduledPost, ScheduledPostsState} from '@mattermost/types/schedule_post'; + +import {ScheduledPostTypes, UserTypes} from 'mattermost-redux/action_types'; + +function byId(state: ScheduledPostsState['byId'] = {}, action: AnyAction) { + switch (action.type) { + case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: { + const {scheduledPostsByTeamId} = action.data; + const newState = {...state}; + + Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => { + if (scheduledPostsByTeamId.hasOwnProperty(teamId)) { + scheduledPostsByTeamId[teamId].forEach((scheduledPost: ScheduledPost) => { + newState[scheduledPost.id] = scheduledPost; + }); + } + }); + + return newState; + } + case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: { + const scheduledPost = action.data.scheduledPost; + return { + ...state, + [scheduledPost.id]: scheduledPost, + }; + } + case ScheduledPostTypes.SCHEDULED_POST_UPDATED: { + const scheduledPost = action.data.scheduledPost; + return { + ...state, + [scheduledPost.id]: scheduledPost, + }; + } + case ScheduledPostTypes.SCHEDULED_POST_DELETED: { + const scheduledPost = action.data.scheduledPost; + const newState = {...state}; + delete newState[scheduledPost.id]; + return newState; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function byTeamId(state: ScheduledPostsState['byTeamId'] = {}, action: AnyAction) { + switch (action.type) { + case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: { + const {scheduledPostsByTeamId} = action.data; + const newState = {...state}; + + Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => { + if (scheduledPostsByTeamId.hasOwnProperty(teamId)) { + newState[teamId] = scheduledPostsByTeamId[teamId].map((scheduledPost: ScheduledPost) => scheduledPost.id); + } + }); + + return newState; + } + case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: { + const scheduledPost = action.data.scheduledPost as ScheduledPost; + const teamId = action.data.teamId || 'directChannels'; + + const newState = {...state}; + + const existingIndex = newState[teamId].findIndex((existingScheduledPostId) => existingScheduledPostId === scheduledPost.id); + if (existingIndex >= 0) { + newState[teamId].splice(existingIndex, 1); + } + + if (newState[teamId]) { + newState[teamId] = [...newState[teamId], scheduledPost.id]; + } else { + newState[teamId] = [scheduledPost.id]; + } + + return newState; + } + case ScheduledPostTypes.SCHEDULED_POST_DELETED: { + const scheduledPost = action.data.scheduledPost as ScheduledPost; + + const newState = {...state}; + let modified = false; + + for (const teamId of Object.keys(state)) { + const index = newState[teamId].findIndex((existingScheduledPostId) => existingScheduledPostId === scheduledPost.id); + + if (index >= 0) { + newState[teamId] = [...newState[teamId]]; + newState[teamId].splice(index, 1); + modified = true; + + break; + } + } + + return modified ? newState : state; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +function errorsByTeamId(state: ScheduledPostsState['errorsByTeamId'] = {}, action: AnyAction) { + switch (action.type) { + case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: { + const {scheduledPostsByTeamId} = action.data; + const newState = {...state}; + + Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => { + if (scheduledPostsByTeamId.hasOwnProperty(teamId)) { + const teamScheduledPosts = scheduledPostsByTeamId[teamId] as ScheduledPost[]; + newState[teamId] = teamScheduledPosts.filter((scheduledPost) => scheduledPost.error_code).map((scheduledPost) => scheduledPost.id); + } + }); + + return newState; + } + case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: { + let changed = false; + + const teamId = action.data.teamId || 'directChannels'; + const newState = {...state}; + if (!newState[teamId]) { + newState[teamId] = []; + } + + const scheduledPost = action.data.scheduledPost as ScheduledPost; + if (scheduledPost.error_code) { + const alreadyExists = newState[teamId].find((scheduledPostId) => scheduledPostId === scheduledPost.id); + if (!alreadyExists) { + newState[teamId] = [...newState[teamId], scheduledPost.id]; + changed = true; + } + } + + return changed ? newState : state; + } + case ScheduledPostTypes.SCHEDULED_POST_DELETED: { + let changed = false; + + const scheduledPost = action.data.scheduledPost as ScheduledPost; + const newState = {...state}; + + for (const teamId of Object.keys(state)) { + const index = newState[teamId].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id); + + if (index >= 0) { + changed = true; + newState[teamId] = [...newState[teamId]]; + newState[teamId].splice(index, 1); + break; + } + } + return changed ? newState : state; + } + case UserTypes.LOGOUT_SUCCESS: { + return {}; + } + default: + return state; + } +} + +function byChannelOrThreadId(state: ScheduledPostsState['byChannelOrThreadId'] = {}, action: AnyAction) { + switch (action.type) { + case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: { + const {scheduledPostsByTeamId} = action.data; + const newState = {...state}; + + Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => { + if (scheduledPostsByTeamId.hasOwnProperty(teamId)) { + scheduledPostsByTeamId[teamId].forEach((scheduledPost: ScheduledPost) => { + const id = scheduledPost.root_id || scheduledPost.channel_id; + + if (newState[id]) { + newState[id].push(scheduledPost.id); + } else { + newState[id] = [scheduledPost.id]; + } + }); + } + }); + + return newState; + } + case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: { + const scheduledPost = action.data.scheduledPost; + const newState = {...state}; + const id = scheduledPost.root_id || scheduledPost.channel_id; + + if (!newState[id]) { + newState[id] = [scheduledPost.id]; + return newState; + } + + let changed = false; + const existingIndex = newState[id].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id); + + if (existingIndex) { + newState[id] = [...newState[id], scheduledPost.id]; + changed = true; + } + + return changed ? newState : state; + } + case ScheduledPostTypes.SCHEDULED_POST_DELETED: { + const scheduledPost = action.data.scheduledPost; + const id = scheduledPost.root_id || scheduledPost.channel_id; + + if (!state[id]) { + return state; + } + + const newState = {...state}; + const index = newState[id].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id); + newState[id] = [...newState[id]]; + newState[id].splice(index, 1); + + return newState; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +export default combineReducers({ + byId, + byTeamId, + byChannelOrThreadId, + errorsByTeamId, +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts index 171c4e1bb0..4927522935 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts @@ -157,3 +157,7 @@ export const getUsersStatusAndProfileFetchingPollInterval: (state: GlobalState) return null; }, ); + +export function developerModeEnabled(state: GlobalState): boolean { + return state.entities.general.config.EnableDeveloper === 'true'; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/scheduled_posts.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/scheduled_posts.ts new file mode 100644 index 0000000000..594642394a --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/scheduled_posts.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ScheduledPost, ScheduledPostsState} from '@mattermost/types/schedule_post'; +import type {GlobalState} from '@mattermost/types/store'; + +import {createSelector} from 'mattermost-redux/selectors/create_selector'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; + +const emptyList: string[] = []; + +export type ChannelScheduledPostIndicatorData = { + scheduledPost?: ScheduledPost; + count: number; +} + +export function makeGetScheduledPostsByTeam(): (state: GlobalState, teamId: string, includeDirectChannels: boolean) => ScheduledPost[] { + return createSelector( + 'makeGetScheduledPostsByTeam', + (state: GlobalState) => state.entities.scheduledPosts.byId, + (state: GlobalState, teamId: string, includeDirectChannels: boolean) => includeDirectChannels, + (state: GlobalState, teamId: string) => state.entities.scheduledPosts.byTeamId[teamId] || emptyList, + (state: GlobalState) => state.entities.scheduledPosts.byTeamId.directChannels || emptyList, + (scheduledPostsById: ScheduledPostsState['byId'], includeDirectChannels: boolean, teamScheduledPostsIDs: string[], directChannelScheduledPostsIDs: string[]) => { + const scheduledPosts: ScheduledPost[] = []; + + const extractor = (scheduledPostId: string) => { + const scheduledPost = scheduledPostsById[scheduledPostId]; + if (scheduledPost) { + scheduledPosts.push(scheduledPost); + } + }; + + teamScheduledPostsIDs.forEach(extractor); + + if (includeDirectChannels) { + directChannelScheduledPostsIDs.forEach(extractor); + } + + // Most recently upcoming post shows up first. + scheduledPosts.sort((a, b) => a.scheduled_at - b.scheduled_at || a.create_at - b.create_at); + + return scheduledPosts; + }, + ); +} + +export function getScheduledPostsByTeamCount(state: GlobalState, teamId: string, includeDirectChannels: boolean) { + let count = state.entities.scheduledPosts.byTeamId[teamId]?.length || 0; + if (includeDirectChannels) { + count += (state.entities.scheduledPosts.byTeamId.directChannels?.length || 0); + } + + return count; +} + +export function hasScheduledPostError(state: GlobalState, teamId: string) { + return state.entities.scheduledPosts.errorsByTeamId[teamId]?.length > 0 || state.entities.scheduledPosts.errorsByTeamId.directChannels?.length > 0; +} + +export function showChannelOrThreadScheduledPostIndicator(state: GlobalState, channelOrThreadId: string): ChannelScheduledPostIndicatorData { + const allChannelScheduledPosts = state.entities.scheduledPosts.byChannelOrThreadId[channelOrThreadId] || emptyList; + const eligibleScheduledPosts = allChannelScheduledPosts.filter((scheduledPostId: string) => { + const scheduledPost = state.entities.scheduledPosts.byId[scheduledPostId]; + return !scheduledPost.error_code; + }); + + const data = { + count: eligibleScheduledPosts.length, + } as ChannelScheduledPostIndicatorData; + + if (data.count === 1) { + const scheduledPostId = eligibleScheduledPosts[0]; + data.scheduledPost = state.entities.scheduledPosts.byId[scheduledPostId]; + } + + return data; +} + +export function isScheduledPostsEnabled(state: GlobalState) { + return getConfig(state).ScheduledPosts === 'true'; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index 3bb4f01e61..a00cfd8744 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -211,6 +211,12 @@ const state: GlobalState = { teamsLoaded: false, }, }, + scheduledPosts: { + byId: {}, + byTeamId: {}, + errorsByTeamId: {}, + byChannelOrThreadId: {}, + }, }, errors: [], requests: { diff --git a/webapp/channels/src/sass/components/_badge.scss b/webapp/channels/src/sass/components/_badge.scss index 34e6264252..9d2f909bb7 100644 --- a/webapp/channels/src/sass/components/_badge.scss +++ b/webapp/channels/src/sass/components/_badge.scss @@ -25,6 +25,17 @@ color: #fff; } } + + .badge, + .badge.persistent { + background: var(--mention-bg); + color: var(--mention-color); + + &.urgent { + background-color: var(--dnd-indicator); + color: #fff; + } + } } .team-sidebar, diff --git a/webapp/channels/src/sass/components/_modal.scss b/webapp/channels/src/sass/components/_modal.scss index 19e754568a..d640a9e719 100644 --- a/webapp/channels/src/sass/components/_modal.scss +++ b/webapp/channels/src/sass/components/_modal.scss @@ -257,7 +257,6 @@ .modal-header { display: flex; min-height: 76px; - align-items: center; justify-content: space-between; padding: 16px 64px 16px 32px; border: none; diff --git a/webapp/channels/src/sass/components/_post.scss b/webapp/channels/src/sass/components/_post.scss index b4faa2ee50..847b1c75ba 100644 --- a/webapp/channels/src/sass/components/_post.scss +++ b/webapp/channels/src/sass/components/_post.scss @@ -655,7 +655,6 @@ .custom-textarea { max-height: calc(50vh - 40px); - padding: 13px 16px 12px 16px; background-color: var(--center-channel-bg); &.custom-textarea--emoji-picker { diff --git a/webapp/channels/src/selectors/preferences.ts b/webapp/channels/src/selectors/preferences.ts index 434b1ba2f6..6749d88687 100644 --- a/webapp/channels/src/selectors/preferences.ts +++ b/webapp/channels/src/selectors/preferences.ts @@ -15,3 +15,21 @@ export const arePreviewsCollapsed = (state: GlobalState) => { Preferences.COLLAPSE_DISPLAY_DEFAULT !== 'false', ); }; + +export const isSendOnCtrlEnter = (state: GlobalState) => { + return getBoolPreference( + state, + Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + false, + ); +}; + +export const isUseMilitaryTime = (state: GlobalState) => { + return getBoolPreference( + state, + Preferences.CATEGORY_DISPLAY_SETTINGS, + Preferences.USE_MILITARY_TIME, + false, + ); +}; diff --git a/webapp/channels/src/types/store/draft.ts b/webapp/channels/src/types/store/draft.ts index f457951f7c..9557c61a74 100644 --- a/webapp/channels/src/types/store/draft.ts +++ b/webapp/channels/src/types/store/draft.ts @@ -3,6 +3,7 @@ import type {FileInfo} from '@mattermost/types/files'; import type {PostPriority} from '@mattermost/types/posts'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; export type DraftInfo = { id: string; @@ -28,3 +29,19 @@ export type PostDraft = { }; }; }; + +export function scheduledPostToPostDraft(scheduledPost: ScheduledPost): PostDraft { + return { + message: scheduledPost.message, + fileInfos: scheduledPost.metadata?.files || [], + uploadsInProgress: [], + props: scheduledPost.props, + channelId: scheduledPost.channel_id, + rootId: scheduledPost.root_id, + createAt: 0, + updateAt: 0, + metadata: { + priority: scheduledPost.priority, + }, + }; +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 0e020733cf..eb0f22e573 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -463,6 +463,7 @@ export const ModalIdentifiers = { CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete', CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create', CONFIRM_MANAGE_USER_SETTINGS_MODAL: 'confirm_switch_to_settings', + SCHEDULED_POST_CUSTOM_TIME_MODAL: 'scheduled_post_custom_time', SECURE_CONNECTION_DELETE: 'secure_connection_delete', SECURE_CONNECTION_CREATE_INVITE: 'secure_connection_create_invite', SECURE_CONNECTION_ACCEPT_INVITE: 'secure_connection_accept_invite', @@ -2216,4 +2217,7 @@ export const PageLoadContext = { RECONNECT: 'reconnect', } as const; +export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts'; + export default Constants; + diff --git a/webapp/channels/src/utils/datetime.ts b/webapp/channels/src/utils/datetime.ts index 732a714bd2..21a31036ee 100644 --- a/webapp/channels/src/utils/datetime.ts +++ b/webapp/channels/src/utils/datetime.ts @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import moment from 'moment-timezone'; +import {DateTime} from 'luxon'; +import moment, {type Moment} from 'moment-timezone'; +import type {useIntl} from 'react-intl'; const shouldTruncate = new Map([ ['year', true], @@ -88,3 +90,16 @@ export function toUTCUnix(date: Date): number { return Math.round(new Date(date.toISOString()).getTime() / 1000); } +export function relativeFormatDate(date: Moment, formatMessage: ReturnType['formatMessage'], format = 'yyyy-MM-dd'): string { + const now = moment(); + const inputDate = moment(date); + + if (inputDate.isSame(now, 'day')) { + return formatMessage({id: 'date_separator.today', defaultMessage: 'Today'}); + } else if (inputDate.isSame(now.clone().subtract(1, 'days'), 'day')) { + return formatMessage({id: 'date_separator.yesterday', defaultMessage: 'Yesterday'}); + } else if (inputDate.isSame(now.clone().add(1, 'days'), 'day')) { + return formatMessage({id: 'date_separator.tomorrow', defaultMessage: 'Tomorrow'}); + } + return DateTime.fromJSDate(date.toDate()).toFormat(format); +} diff --git a/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx index 5bd4ce7f3a..29ae198745 100644 --- a/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx +++ b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx @@ -37,6 +37,11 @@ describe('identifyElementRegion', () => { const user = TestHelper.getUserMock({ id: 'test-user-id', roles: 'system_admin system_user', + timezone: { + useAutomaticTimezone: 'true', + automaticTimezone: 'America/New_York', + manualTimezone: '', + }, }); const post = TestHelper.getPostMock({ id: 'test-post-id', diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index b46c5d825f..b42da6f8a4 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -114,6 +114,7 @@ import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, Remot import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports'; import type {Role} from '@mattermost/types/roles'; import type {SamlCertificateStatus, SamlMetadataResponse} from '@mattermost/types/saml'; +import type {ScheduledPost} from '@mattermost/types/schedule_post'; import type {Scheme} from '@mattermost/types/schemes'; import type {Session} from '@mattermost/types/sessions'; import type {CompleteOnboardingRequest} from '@mattermost/types/setup'; @@ -4419,6 +4420,36 @@ export default class Client4 { {method: 'post', body: JSON.stringify(body)}, ); }; + + // Schedule Post methods + createScheduledPost = (schedulePost: ScheduledPost, connectionId: string) => { + return this.doFetchWithResponse( + `${this.getPostsRoute()}/schedule`, + {method: 'post', body: JSON.stringify(schedulePost), headers: {'Connection-Id': connectionId}}, + ); + }; + + // get user's current team's scheduled posts + getScheduledPosts = (teamId: string, includeDirectChannels: boolean) => { + return this.doFetchWithResponse<{[key: string]: ScheduledPost[]}>( + `${this.getPostsRoute()}/scheduled/team/${teamId}?includeDirectChannels=${includeDirectChannels}`, + {method: 'get'}, + ); + }; + + updateScheduledPost = (schedulePost: ScheduledPost, connectionId: string) => { + return this.doFetchWithResponse( + `${this.getPostsRoute()}/schedule/${schedulePost.id}`, + {method: 'put', body: JSON.stringify(schedulePost), headers: {'Connection-Id': connectionId}}, + ); + }; + + deleteScheduledPost = (schedulePostId: string, connectionId: string) => { + return this.doFetchWithResponse( + `${this.getPostsRoute()}/schedule/${schedulePostId}`, + {method: 'delete', headers: {'Connection-Id': connectionId}}, + ); + }; } export function parseAndMergeNestedHeaders(originalHeaders: any) { diff --git a/webapp/platform/components/src/generic_modal/__snapshots__/generic_modal.test.tsx.snap b/webapp/platform/components/src/generic_modal/__snapshots__/generic_modal.test.tsx.snap index f237fec09d..83e41d8c6b 100644 --- a/webapp/platform/components/src/generic_modal/__snapshots__/generic_modal.test.tsx.snap +++ b/webapp/platform/components/src/generic_modal/__snapshots__/generic_modal.test.tsx.snap @@ -54,6 +54,9 @@ exports[`GenericModal should match snapshot for base case 1`] = ` Close +