Feature scheduled messages (#28932)

* Create scheduled post api (#27920)

* Added migration files for Postgres

* Added migrations for MySQL

* Added store method

* Added API and store tests

* Renamed migration after syncing with master

* Added app layer tests

* API is ready

* API is ready

* API is ready

* Renamed migration after syncing with master

* Updated migration list

* Fixed retry layer tests

* Allowed posts with empty messages

* Review fixes

* Reverted an incorrect change

* Renamed migration and fixed ID assignment

* CI

* Send post button changes (#28019)

* added Split button

* WIP

* Added core menu options

* WIP

* WIP

* WIP

* Handled displaying error in creating scheduled post

* lint fixes

* webapp i18n fix

* Review fixes

* Fixed a webapp test

* A few more fixes

* Removed a duplicate comment

* Scheduled post job (#28088)

* Added the job function

* Added query for fetching scheduled posts for pricessing

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Reafactoring of scheduled post job

* Lint fixes

* Updated i18n files

* FInishing touches

* Added tests for GetScheduledPosts

* Added tests for PermanentlyDeleteScheduledPosts

* Updated all layer

* Some changes as discussed with team

* Added tests for UpdatedScheduledPost

* Code review refactoring

* Added job test

* MM-60120 - Custom time selection (#28120)

* Added a common date time picker modal and used it for post reminder

* Added a common date time picker modal and used it for post reminderggp

* Added modal for custom schedule time and fixed TZ issue

* WIP

* Removed event from useSubmit hook

* Removed event from useSubmit hook

* Added timezone handling

* fixed type error

* Updated i18n strings

* Minor cleanup

* updated snapshots

* review fixes

* Handled event

* Supported for having a DM thread open in RHS while in a regular channel

* Review fixes

* MM-60136 - Scheduled messages tab (#28133)

* WIP

* WIP

* Created Tabs and Tab wrapper with added styling

* Added API to get scheduled posts

* WIP

* Displated scheduled post count

* i18n fix

* Added tests

* Handled asetting active tab absed on URL:

* Reverted unintended change

* Added API to client ad OpenAPI specs

* Renamed file

* Adding fileinfo to schedule posts

* Partial review fixes

* Made get scheduled post API return posts by teamID

* review fixes

* Moved scheduled post redux code to MM-redux package

* Usedd selector factory

* WIP:

* WIP:

* Lint fix

* Fixed an incorrect openapi spec file

* Removed redundent permission check

* Clreaed scheduled post data on logout

* Removed unused i18n string:

* lint fix

* Render scheduled posts (#28208)

* WIP

* WIP

* Created Tabs and Tab wrapper with added styling

* Added API to get scheduled posts

* WIP

* Displated scheduled post count

* i18n fix

* Added tests

* Handled asetting active tab absed on URL:

* Reverted unintended change

* Added API to client ad OpenAPI specs

* Renamed file

* Created common component for draft list item

* WIP

* WIP

* Adding fileinfo to schedule posts

* Basic rendering

* Added count badge to tabs

* WIP

* Made the Drafts LHS iteam appear if no drafts exist but scheduled posts do

* Fixed icon size

* Partial review fixes

* Made get scheduled post API return posts by teamID

* Handled initial vs team switch load

* Displayed scheduled date in panel header

* Added error message and error indiocator

* WIP

* review fixes

* WIP Adding error reason tag

* Added error codes

* Moved scheduled post redux code to MM-redux package

* Usedd selector factory

* WIP:

* WIP:

* Lint fix

* Fixed an incorrect openapi spec file

* Removed redundent permission check

* Clreaed scheduled post data on logout

* Removed unused i18n string:

* lint fix

* Opened rescheduling modal

* Updated graphic for empty state of schduled post list

* Added delete scheduled post option and modal

* Badge and timezone fix

* WIP:

* Added send now confirmation modal

* lint

* Webapp i18n fix

* Fixed webapp test

* Fixed a bug where DM/GM scheduled posts weren't immideatly showing up in UI

* Minor fixes

* WIP

* Review fixes

* Review fixes

* Optimisations

* Fixed reducer name

* Moment optimizatin

* Updated route check

* MM-60144 - added API to update a scheduled post (#28248)

* WIP

* Added api and ap layer for update scheduled post ̛̦̄

* Added API to OpenAI specs, Go client and TS client

* removed permissio check

* Added tests

* Fixed tests

* Added PreUpdate method on scheduled post model

* MM-60131 - Reschedule post integration (#28281)

* Handled rescheduling post in webapp

* Added error handling

* MM-60146 - Delete scheduled post api (#28265)

* WIP

* Added api and ap layer for update scheduled post ̛̦̄

* Added API to OpenAI specs, Go client and TS client

* removed permissio check

* Added tests

* Fixed tests

* Added PreUpdate method on scheduled post model

* Added delete scheduled post API

* Added API to Go client and OpenAPI specs

* Added API to TS client

* Added tests

* CI

* Rmeoved two incorrect code comments

* MM-60653 - Integrated delete scheduled post API (#28296)

* Integrated delete scheduled apost API

* Lint fix

* Review fixes

* Excluded draft checks from scheduled posts (#28370)

* Excluded draft checks from scheduled posts

* Added a removed todo

* MM-60125 - Scheduled post channel indicator (#28320)

* Integrated delete scheduled apost API

* Lint fix

* Added state for storing scheduled posts by channel ID

* Refactored redux store to store scheudled posts by ID, thens tore IDs everywhere

* Refactored redux store to store scheudled posts by ID, thens tore IDs everywhere

* WIP

* Added scheduled post indiocator

* Handled single and multiple scheudled posts

* Review fixes

* Fixed styling and handled center channel, RHS and threads view

* Lint fix

* i18n fix

* Fixed a cycling dependency

* Lint fix

* Added some more comments

* Updated styling

* Review fixes

* Added common component for remote user time and scheduled post indicator

* Updated scheduled post count

* Minor change

* Moved CSS code around

* Fixed a bug where files in scheduled post didn't show up until refresh (#28359)

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>

* Scheduled post config (#28485)

* Added config

* Added config on server and webapp side

* Added config check in server and webapp

* Added license check

* Added license check

* Added placeholder help text

* Added license check to job

* Fixed job test

* Review fixes

* Updated English text

* Review fixes

* MM-60118 - Added index on ScheduledPosts table (#28579)

* Added index

* Updated indexes

* Scheduled posts misc fixes (#28625)

* Added detailed logging for scheduled post job

* Limited scheduled posts processing to 24 hours

* Marked old scheduled posts as unable to send

* Added t5ests

* converted some logs to trace level

* Fixed a bug causing error message to show up on deleting a scheduled post in a deleted thread (#28630)

* Fixed scheduled posts link in RHS (#28659)

* Fixed scheduled posts link in RHS

* Review fixes

* Fix permission name in scheduled posts by team (#28580)

* Fix permission name

* fix wording

---------

Co-authored-by: Mattermost Build <build@mattermost.com>

* FIxed width of generic modal header to fix browser channel modal (#28639)

* Only consider error-free scheduled posts for indicator in channel and RHS (#28683)

* Show only errro free scheudled posts in post box indicator

* Fixed a bug to handle no scheduled posts

* Fixed draft and scheudled post UI in mobile view (#28680)

* MM-60873 and MM-60872 - Fixed a bug with updating scheduled posts (#28656)

* Fixed a bug with updating scheduled posts

* Better selectors

* MOved shceuled post message length validation to app layer

* MM-60732 - Scheduled posts channel link now takes you to the first scheduled post in channel/thread in list (#28768)

* Ordered scheudle dposts by schgeudled at nad create at

* Ordered in client

* Added scroll to target

* Removed classname prop

* Fixed tests

* Added doc

* Import fix

* MM-60961 - Fixed a bug where API used incoming create at date for scheduled post (#28703)

* Fixed a bug where API used incoming create at date for scheduled post

* Stopped sending created at value for scheduled post

* MM-60785 - Fixed a bug where scheduled posts of channel we are no longer member of didn't show up (#28637)

* Fixed a bug where scheduled posts of channel we are no longer member of didn't show up

* Added a comment

* CI

* Used data loader to optimise laoding missing channels

* Minor refactoring

* MM-60963 - Added common checks for post and scheduled posts (#28713)

* Added commen checks for post and scheuled posts

* Sanitised scheduled posts

* Fixed tests

* Splitted post checks into app and context functions

* Added checks on scheduiled posts job as well:

* i18n fix

* Fixed a test

* Renamed a func

* removed duplicate check

* Scheduled posts UI fixes (#28828)

* Fixed send button and time picker borders

* Fixed center alignment of time picker

* Removed on for today and tomorrow

* Lint fix

* Date time modal hover state fix

* Badge fix

* Fixed a mnerge issue

* Scheduled Post send now and add schedule on draft (#28851)

* Added send now option on scheduled posts

* Minor refactoring

* WIP

* WIP

* WIP

* Lint fix

* i18n fix

* Snapshot update

* Review fixes

* Scheduled post inline editing (#28893)

* Added send now option on scheduled posts

* Minor refactoring

* WIP

* WIP

* WIP

* Lint fix

* i18n fix

* Snapshot update

* Displayed editing component in scheduled post

* Added handling for updating scheduled post

* Handle events

* Fixed escape key issue in scheudled post editing

* Fixes

* Displayed error message for editing error

* Don't show mention warning

* Handled dev mode (#28918)

* MInor fixes

* client fix

* Fixes

* CI

* Removed dev mode behaviour temperorily (#29008)

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Eva Sarafianou <eva.sarafianou@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Harshil Sharma
2024-11-04 11:39:35 +05:30
committed by GitHub
parent aaf9234c8e
commit e281b3f37e
135 changed files with 7792 additions and 684 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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),
)
}
}
}

View File

@@ -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))
})
}

View File

@@ -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)
})
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS scheduledposts;
DROP INDEX IF EXISTS idx_scheduledposts_userid_channel_id_scheduled_at;

View File

@@ -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);

View File

@@ -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}

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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)
})
}

View File

@@ -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,
)
}

View File

@@ -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}

View File

@@ -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)
}
}

View File

@@ -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."

View File

@@ -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"
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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<boolean, GlobalState> {
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<PostActions.CreatePostReturnType, GlobalState> {
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<PostActions.CreatePostReturnType, GlobalState> {
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<PostActions.CreatePostReturnType, GlobalState> {
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));

View File

@@ -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<CreatePostReturnType, GlobalState> {
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<SubmitPostReturnType, GlobalState> {
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));
};
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 : (
<SendButton
disabled={disableSendButton}
handleSubmit={handleSubmit}
handleSubmit={handleSubmitPostAndScheduledMessage}
channelId={channelId}
/>
);
@@ -576,12 +579,12 @@ const AdvancedTextEditor = ({
<FileLimitStickyBanner/>
)}
{showDndWarning && <DoNotDisturbWarning displayName={teammateDisplayName}/>}
{showRemoteUserHour && (
<RemoteUserHour
teammateId={teammateId}
displayName={teammateDisplayName}
/>
)}
<PostBoxIndicator
channelId={channelId}
teammateDisplayName={teammateDisplayName}
location={location}
postId={postId}
/>
<div
className={classNames('AdvancedTextEditor', {
'AdvancedTextEditor__attachment-disabled': !canUploadFiles,

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React, {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import RemoteUserHour from 'components/advanced_text_editor/remote_user_hour';
import ScheduledPostIndicator from 'components/advanced_text_editor/scheduled_post_indicator/scheduled_post_indicator';
import Constants, {UserStatuses} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './style.scss';
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
type Props = {
channelId: string;
teammateDisplayName: string;
location: string;
postId: string;
}
export default function PostBoxIndicator({channelId, teammateDisplayName, location, postId}: Props) {
const teammateId = useSelector((state: GlobalState) => 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 (
<div className='postBoxIndicator'>
{
showRemoteUserHour &&
<RemoteUserHour
displayName={teammateDisplayName}
timestamp={timestamp}
teammateTimezone={teammateTimezone}
/>
}
{
isScheduledPostEnabled &&
<ScheduledPostIndicator
location={location}
channelId={channelId}
postId={postId}
remoteUserHourDisplayed={showRemoteUserHour}
/>
}
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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 (
<Container>
<Icon/>
<Container className='RemoteUserHour'>
<Icon className='icon moonIcon'/>
<FormattedMessage
id='advanced_text_editor.remote_user_hour'
defaultMessage='The time for {user} is {time}'
values={{
user: displayName,
user: (
<span className='userDisplayName'>
{displayName}
</span>
),
time: (
<Timestamp
useRelative={false}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ScheduledPostIndicator {
display: flex;
flex-wrap: wrap;
align-items: end;
padding: 8px 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
gap: 5px;
i.icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}

View File

@@ -0,0 +1,106 @@
// 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 {useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
import {showChannelOrThreadScheduledPostIndicator} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {
ShortScheduledPostIndicator,
} from 'components/advanced_text_editor/scheduled_post_indicator/short_scheduled_post_indicator';
import {SCHEDULED_POST_TIME_RANGES, scheduledPostTimeFormat} from 'components/drafts/panel/panel_header';
import Timestamp from 'components/timestamp';
import {Locations} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './scheduled_post_indicator.scss';
type Props = {
location: string;
channelId: string;
postId: string;
remoteUserHourDisplayed?: boolean;
}
export default function ScheduledPostIndicator({location, channelId, postId, remoteUserHourDisplayed}: Props) {
// we use RHS_COMMENT for RHS and threads view, and CENTER for center channel.
// get scheduled posts of a thread if in RHS or threads view,
// else, get those for the channel.
// Fetch scheduled posts for the thread when opening a thread, and fetch for channel
// when opening from center channel.
const id = location === Locations.RHS_COMMENT ? postId : channelId;
const scheduledPostData = useSelector((state: GlobalState) => 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 (
<ShortScheduledPostIndicator
scheduledPostData={scheduledPostData}
scheduledPostLinkURL={scheduledPostLinkURL}
/>
);
}
let scheduledPostText: React.ReactNode;
// display scheduled post's details of there is only one scheduled post
if (scheduledPostData.count === 1 && scheduledPostData.scheduledPost) {
scheduledPostText = (
<FormattedMessage
id='scheduled_post.channel_indicator.single'
defaultMessage='Message scheduled for {dateTime}.'
values={{
dateTime: (
<Timestamp
value={scheduledPostData.scheduledPost.scheduled_at}
ranges={SCHEDULED_POST_TIME_RANGES}
useSemanticOutput={false}
useTime={scheduledPostTimeFormat}
/>
),
}}
/>
);
}
// display scheduled post count if there are more than one scheduled post
if (scheduledPostData.count > 1) {
scheduledPostText = (
<FormattedMessage
id='scheduled_post.channel_indicator.multiple'
defaultMessage='You have {count} scheduled messages.'
values={{
count: scheduledPostData.count,
}}
/>
);
}
return (
<div className='ScheduledPostIndicator'>
<i
data-testid='scheduledPostIcon'
className='icon icon-draft-indicator icon-clock-send-outline'
/>
{scheduledPostText}
<Link to={scheduledPostLinkURL}>
<FormattedMessage
id='scheduled_post.channel_indicator.link_to_scheduled_posts.text'
defaultMessage='See all scheduled messages'
/>
</Link>
</div>
);
}

View File

@@ -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 (
<div className='ScheduledPostIndicator'>
<FormattedMessage
id='scheduled_post.channel_indicator.with_other_user_late_time'
defaultMessage='You have {count, plural, =1 {one} other {#}} <a>scheduled {count, plural, =1 {message} other {messages}}</a>.'
values={{
count: scheduledPostData.count,
a: (chunks) => (
<Link to={scheduledPostLinkURL}>
{chunks}
</Link>
),
}}
/>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<Timestamp
ranges={DATE_RANGES}
userTimezone={dmUser.timezone}
useTime={{
hour: 'numeric',
minute: 'numeric',
}}
value={selectedTime}
/>
);
}, [dmUser, selectedTime]);
if (!channel || channel.type !== 'D') {
return null;
}
return (
<div className='DMUserTimezone'>
<FormattedMessage
id='schedule_post.custom_time_modal.dm_user_time'
defaultMessage='{dmUserTime} for {dmUserName}'
values={{
dmUserTime,
dmUserName,
}}
/>
</div>
);
}

View File

@@ -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<string>();
const userTimezone = useSelector(getCurrentTimezone);
const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => {
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 (
<DMUserTimezone
channelId={channelId}
selectedTime={selectedDateTime?.toDate()}
/>
);
}, [channelId, selectedDateTime]);
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
return (
<DateTimePickerModal
className='scheduled_post_custom_time_modal'
initialTime={selectedDateTime}
header={
<FormattedMessage
id='schedule_post.custom_time_modal.title'
defaultMessage='Schedule message'
/>
}
subheading={userTimezoneLabel}
confirmButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.confirm_button_text'
defaultMessage='Confirm'
/>
}
cancelButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.cancel_button_text'
defaultMessage='Cancel'
/>
}
ariaLabel={label}
onExited={onExited}
onConfirm={handleOnConfirm}
onChange={setSelectedDateTime}
bodySuffix={bodySuffix}
relativeDate={true}
onCancel={onExited}
errorText={errorMessage}
/>
);
}

View File

@@ -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;
}
}

View File

@@ -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<ShortcutDefinition>(() => {
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 (
<SendButtonContainer
data-testid='SendMessageButton'
tabIndex={0}
aria-label={formatMessage({
id: 'create_post.send_message',
defaultMessage: 'Send a message',
})}
disabled={disabled}
onClick={sendMessage}
>
<SendIcon
size={18}
color='currentColor'
aria-label={formatMessage({
id: 'create_post.icon',
defaultMessage: 'Create a post',
})}
/>
</SendButtonContainer>
<div className={classNames('splitSendButton', {disabled, scheduledPost: isScheduledPostEnabled})}>
<WithTooltip
placement='top'
id='send_post_now_tooltip'
title={formatMessage({id: 'create_post_button.option.send_now', defaultMessage: 'Send Now'})}
shortcut={sendNowKeyboardShortcutDescriptor}
disabled={disabled}
>
<button
className={classNames('SendMessageButton', {disabled})}
data-testid='SendMessageButton'
tabIndex={0}
aria-label={formatMessage({
id: 'create_post_button.option.send_now',
defaultMessage: 'Send Now',
})}
disabled={disabled}
onClick={sendMessage}
>
<SendIcon
size={18}
color='currentColor'
/>
</button>
</WithTooltip>
{
isScheduledPostEnabled &&
<SendPostOptions
disabled={disabled}
onSelect={handleSubmit}
channelId={channelId}
/>
}
</div>
);
};

View File

@@ -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 = (
<Timestamp
value={tomorrow9amTime.valueOf()}
useDate={false}
/>
);
const tomorrowClickHandler = useCallback((e) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
const optionTomorrow = (
<Menu.Item
key={'scheduling_time_tomorrow_9_am'}
onClick={tomorrowClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.tomorrow'
defaultMessage='Tomorrow at {9amTime}'
values={{'9amTime': timeComponent}}
/>
}
/>
);
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 = (
<Menu.Item
key={'scheduling_time_next_monday_9_am'}
onClick={nextMondayClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.next_monday'
defaultMessage='Next Monday at {9amTime}'
values={{'9amTime': timeComponent}}
/>
}
/>
);
const optionMonday = (
<Menu.Item
key={'scheduling_time_monday_9_am'}
onClick={nextMondayClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.monday'
defaultMessage='Monday at {9amTime}'
values={{
'9amTime': timeComponent,
}}
/>
}
/>
);
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 (
<React.Fragment>
{options}
</React.Fragment>
);
}
export default memo(CoreMenuOptions);

View File

@@ -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 (
<Menu.Container
hideTooltipWhenDisabled={true}
menuButtonTooltip={{
id: 'send_post_option_schedule_post',
text: formatMessage({
id: 'create_post_button.option.schedule_message',
defaultMessage: 'Schedule message',
}),
}}
menuButton={{
id: 'button_send_post_options',
class: classNames('button_send_post_options', {disabled}),
children: <ChevronDownIcon size={16}/>,
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',
}}
>
<Menu.Item
disabled={true}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.header'
defaultMessage='Scheduled message'
/>
}
/>
<CoreMenuOptions handleOnSelect={handleOnSelect}/>
<Menu.Separator/>
<Menu.Item
onClick={handleChooseCustomTime}
key={'choose_custom_time'}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.choose_custom_time'
defaultMessage='Choose a custom time'
/>
}
/>
</Menu.Container>
);
}

View File

@@ -0,0 +1,9 @@
ul#dropdown_send_post_options {
li[role="menuitem"][aria-disabled="true"] {
opacity: 1;
.label-elements {
font-weight: bold;
}
}
}

View File

@@ -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,

View File

@@ -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) ? (
<PriorityLabels
canRemove={!shouldShowPreview}
hasError={!isValidPersistentNotifications}
@@ -144,10 +146,10 @@ const usePriority = (
requestedAck={draft!.metadata!.priority?.requested_ack}
/>
) : undefined
), [shouldShowPreview, draft, hasPrioritySet, isValidPersistentNotifications, specialMentions, handleRemovePriority]);
), [hasPrioritySet, rootId, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
const additionalControl = useMemo(() =>
!draft.rootId && isPostPriorityEnabled && (
!rootId && isPostPriorityEnabled && (
<PostPriorityPickerOverlay
key='post-priority-picker-key'
settings={draft.metadata?.priority}
@@ -155,7 +157,7 @@ const usePriority = (
onClose={handlePostPriorityHide}
disabled={shouldShowPreview}
/>
), [draft.rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]);
), [rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]);
return {
labels,

View File

@@ -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<void>,
(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,

View File

@@ -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<Props, State> {
path={`/:team(${TEAM_NAME_PATH_PATTERN})/drafts`}
component={Drafts}
/>
<Route
path={`/:team(${TEAM_NAME_PATH_PATTERN})/${SCHEDULED_POST_URL_SUFFIX}`}
component={Drafts}
/>
<Redirect to={lastChannelPath}/>
</Switch>

View File

@@ -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<HTMLElement>(null);
React.useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({behavior: 'smooth'});
}
}, []);
return ref;
}

View File

@@ -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 {

View File

@@ -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: Props) => {
@@ -99,9 +103,9 @@ const DateTimeInputContainer: React.FC<Props> = (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: 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: 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: Props) => {
datePickerProps={datePickerProps}
>
<Input
value={formatDate(time.toDate())}
value={formatDate(time)}
id='customStatus__calendar-input'
readOnly={true}
className='dateTime__calendar-input'
className={classNames('dateTime__calendar-input', {isOpen: isPopperOpen})}
label={formatMessage({id: 'dnd_custom_time_picker_modal.date', defaultMessage: 'Date'})}
onClick={() => handlePopperOpenState(true)}
tabIndex={-1}

View File

@@ -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 (
<GenericModal
id='DateTimePickerModal'
ariaLabel={ariaLabel}
onExited={onExited}
modalHeaderText={header}
modalSubheaderText={subheading}
confirmButtonText={confirmButtonText}
handleConfirm={handleConfirm}
handleCancel={onCancel}
handleEnterKeyPress={handleConfirm}
className={classnames('date-time-picker-modal', className)}
compassDesign={true}
keyboardEscape={false}
cancelButtonText={cancelButtonText}
autoCloseOnConfirmButton={false}
errorText={errorText}
>
{bodyPrefix}
<DateTimeInput
time={dateTime}
handleChange={handleChange}
timezone={userTimezone}
setIsDatePickerOpen={setIsDatePickerOpen}
relativeDate={relativeDate}
timePickerInterval={timePickerInterval}
/>
{bodySuffix}
</GenericModal>
);
}

View File

@@ -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;

View File

@@ -34,12 +34,8 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1`
>
<Memo(DraftRow)
displayName="test"
draft={
Object {
"type": "channel",
}
}
isRemote={false}
item={Object {}}
status={Object {}}
user={Object {}}
/>
@@ -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 {}}
/>

View File

@@ -35,10 +35,13 @@ exports[`components/drafts/draft_actions should match snapshot 1`] = `
<Memo(DraftActions)
canEdit={true}
canSend={true}
channelId=""
displayName=""
draftId=""
itemId=""
onDelete={[MockFunction]}
onEdit={[MockFunction]}
onSchedule={[MockFunction]}
onSend={[MockFunction]}
/>
</ContextProvider>

View File

@@ -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 (
<GenericModal
confirmButtonText={confirmButtonText}
handleCancel={() => {}}
handleCancel={noop}
handleConfirm={onConfirm}
modalHeaderText={title}
onExited={onExited}

View File

@@ -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', () => {

View File

@@ -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 = (
<FormattedMessage
id='drafts.actions.scheduled'
defaultMessage='Schedule draft'
/>
);
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 (
<>
<Action
@@ -82,6 +107,18 @@ function DraftActions({
onClick={onEdit}
/>
)}
{
canSend &&
<Action
icon='icon-clock-send-outline'
id='reschedule'
name='reschedule'
tooltipText={scheduledDraft}
onClick={handleScheduleDraft}
/>
}
{canSend && (
<Action
icon='icon-send-outline'

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import noop from 'lodash/noop';
import React, {useCallback, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
type Props = {
channelDisplayName: string;
onConfirm: () => Promise<{error?: string}>;
onExited: () => void;
}
export default function DeleteScheduledPostModal({
channelDisplayName,
onExited,
onConfirm,
}: Props) {
const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState<string>();
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 (
<GenericModal
className='delete_scheduled_post_modal'
confirmButtonText={confirmButtonText}
handleCancel={noop}
handleConfirm={handleOnConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
autoFocusConfirmButton={true}
autoCloseOnConfirmButton={false}
errorText={errorMessage}
>
<FormattedMessage
id={'scheduled_post.delete_modal.body'}
defaultMessage={'Are you sure you want to delete this scheduled post to <strong>{displayName}</strong>?'}
values={{
strong: (chunk: string) => <strong>{chunk}</strong>,
displayName: channelDisplayName,
}}
/>
</GenericModal>
);
}

View File

@@ -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 = (
<FormattedMessage
id='scheduled_post.action.delete'
defaultMessage='Delete scheduled post'
/>
);
const editTooltipText = (
<FormattedMessage
id='scheduled_post.action.edit'
defaultMessage='Edit scheduled post'
/>
);
const rescheduleTooltipText = (
<FormattedMessage
id='scheduled_post.action.reschedule'
defaultMessage='Reschedule post'
/>
);
const sendNowTooltipText = (
<FormattedMessage
id='scheduled_post.action.send_now'
defaultMessage='Send now'
/>
);
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 (
<div className='ScheduledPostActions'>
<Action
icon='icon-trash-can-outline'
id='delete'
name='delete'
tooltipText={deleteTooltipText}
onClick={handleDelete}
/>
{
!scheduledPost.error_code && (
<React.Fragment>
<Action
icon='icon-pencil-outline'
id='edit'
name='edit'
tooltipText={editTooltipText}
onClick={onEdit}
/>
<Action
icon='icon-clock-send-outline'
id='reschedule'
name='reschedule'
tooltipText={rescheduleTooltipText}
onClick={handleReschedulePost}
/>
<Action
icon='icon-send-outline'
id='sendNow'
name='sendNow'
tooltipText={sendNowTooltipText}
onClick={handleSend}
/>
</React.Fragment>
)
}
</div>
);
}
export default memo(ScheduledPostActions);

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ScheduledPostActions {
display: inline-flex;
}

View File

@@ -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<string, boolean>;
status: UserStatus['status'];
className?: string;
}
export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) {
const {formatMessage} = useIntl();
return (
<div className={classNames('DraftList Drafts__main', className)}>
{drafts.map((d) => (
<DraftRow
key={d.key}
displayName={displayName}
item={d.value}
isRemote={draftRemotes?.[d.key]}
user={user}
status={status}
/>
))}
{drafts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={DraftsIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
)}
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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<typeof DraftRow> = {
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(

View File

@@ -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 (
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
channelId={channel.id}
userId={user.id}
onDelete={handleOnDelete}
onEdit={goToMessage}
onSend={handleOnSend}
canEdit={canEdit}
canSend={canSend}
onSchedule={onScheduleDraft}
/>
);
}, [
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 (
<ScheduledPostActions
scheduledPost={item as ScheduledPost}
channelDisplayName={channel.display_name}
onReschedule={handleSchedulePostOnReschedule}
onDelete={handleSchedulePostOnDelete}
onSend={handleScheduledPostOnSend}
onEdit={handleSchedulePostEdit}
/>
);
}, [
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 = (
<DraftTitle
type={(rootId ? 'thread' : 'channel')}
channel={channel}
userId={user.id}
/>
);
} else {
title = (
<PlaceholderScheduledPostsTitle
type={(rootId ? 'thread' : 'channel')}
/>
);
}
return (
<Panel
onClick={goToMessage}
hasError={Boolean(postError)}
innerRef={scrollIntoView ? alertRef : undefined}
isHighlighted={scrollIntoView}
>
{({hover}) => (
<>
<Header
kind={isScheduledPost ? 'scheduledPost' : 'draft'}
hover={hover}
actions={(
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
userId={user.id}
onDelete={handleOnDelete}
onEdit={goToMessage}
onSend={handleOnSend}
canEdit={canEdit}
canSend={canSend}
/>
)}
title={(
<DraftTitle
type={draft.type}
channel={channel}
userId={user.id}
/>
)}
timestamp={draft.value.updateAt}
actions={actions}
title={title}
timestamp={timestamp}
remote={isRemote || false}
error={postError || serverError?.message}
/>
<PanelBody
channelId={channel.id}
displayName={displayName}
fileInfos={draft.value.fileInfos}
message={draft.value.message}
status={status}
uploadsInProgress={draft.value.uploadsInProgress}
userId={user.id}
username={user.username}
/>
{
isEditing &&
<EditPost
scheduledPost={item as ScheduledPost}
onCancel={handleCancelEdit}
afterSave={handleCancelEdit}
onDeleteScheduledPost={handleSchedulePostOnDelete}
/>
}
{
!isEditing &&
<PanelBody
channelId={channel?.id}
displayName={displayName}
fileInfos={fileInfos}
message={item.message}
status={status}
priority={rootId ? undefined : item.metadata?.priority}
uploadsInProgress={uploadsInProgress}
userId={user.id}
username={user.username}
/>
}
</>
)}
</Panel>

View File

@@ -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;

View File

@@ -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 (
<div className='drafts_tab_title'>
<FormattedMessage
id='schedule_post.tab.heading'
defaultMessage='Scheduled'
/>
{
scheduledPosts?.length > 0 &&
<Badge
className='badge'
badgeContent={scheduledPosts.length}
/>
}
</div>
);
}, [scheduledPosts?.length]);
const draftTabHeading = useMemo(() => {
return (
<div className='drafts_tab_title'>
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
{
drafts.length > 0 &&
<Badge
className='badge'
badgeContent={drafts.length}
/>
}
</div>
);
}, [drafts?.length]);
const heading = useMemo(() => {
return (
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
);
}, []);
const subtitle = useMemo(() => {
return (
<FormattedMessage
id={'drafts.subtitle'}
defaultMessage={'Any messages you\'ve started will show here'}
/>
);
}, []);
const activeTab = isDraftsTab ? 0 : 1;
return (
<div
id='app-content'
@@ -56,41 +146,60 @@ function Drafts({
<Header
level={2}
className='Drafts__header'
heading={formatMessage({
id: 'drafts.heading',
defaultMessage: 'Drafts',
})}
subtitle={formatMessage({
id: 'drafts.subtitle',
defaultMessage: 'Any messages you\'ve started will show here',
})}
heading={heading}
subtitle={subtitle}
/>
<div className='Drafts__main'>
{drafts.map((d) => (
<DraftRow
key={d.key}
displayName={displayName}
draft={d}
isRemote={draftRemotes?.[d.key]}
user={user}
status={status}
/>
))}
{drafts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={DraftsIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
)}
</div>
{
isScheduledPostEnabled &&
<Tabs
id='draft_tabs'
activeKey={activeTab}
mountOnEnter={true}
unmountOnExit={false}
onSelect={handleSwitchTabs}
>
<Tab
eventKey={0}
title={draftTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
>
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
</Tab>
<Tab
eventKey={1}
title={scheduledPostsTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
>
<ScheduledPostList
scheduledPosts={scheduledPosts || EMPTY_LIST}
user={user}
displayName={displayName}
status={status}
/>
</Tab>
</Tabs>
}
{
!isScheduledPostEnabled &&
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
}
</div>
);
}

View File

@@ -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;

View File

@@ -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 = (
<i
data-testid='scheduledPostIcon'
className='icon icon-draft-indicator icon-clock-send-outline'
/>
);
const pencilIcon = (
<i
data-testid='draftIcon'
className='icon icon-draft-indicator icon-pencil-outline'
/>
);
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}
>
<i
data-testid='draftIcon'
className='icon icon-pencil-outline'
/>
{pencilIcon}
<div className='SidebarChannelLinkLabel_wrapper'>
<span className='SidebarChannelLinkLabel sidebar-item__name'>
<FormattedMessage
@@ -66,9 +109,23 @@ function DraftsLink() {
/>
</span>
</div>
{count > 0 && (
<ChannelMentionBadge unreadMentions={count}/>
)}
{
draftCount > 0 &&
<ChannelMentionBadge
unreadMentions={draftCount}
icon={pencilIcon}
/>
}
{
isScheduledPostEnabled && teamScheduledPostCount > 0 &&
<ChannelMentionBadge
unreadMentions={teamScheduledPostCount}
icon={scheduleIcon}
className={classNames('scheduledPostBadge', {persistent: scheduledPostsHasError})}
hasUrgent={scheduledPostsHasError}
/>
}
</NavLink>
<DraftsTourTip/>
</li>

View File

@@ -12,6 +12,8 @@ type Props = {
children: ({hover}: {hover: boolean}) => React.ReactNode;
onClick: () => void;
hasError: boolean;
innerRef?: React.Ref<HTMLElement>;
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})}
</article>

View File

@@ -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;

View File

@@ -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<typeof PanelHeader> = {
kind: 'draft' as const,
actions: <div>{'actions'}</div>,
hover: false,
timestamp: 12345,
remote: false,
title: <div>{'title'}</div>,
error: undefined,
};
it('should match snapshot', () => {

View File

@@ -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<ComponentProps<typeof Timestamp>> = {
day: 'numeric',
@@ -21,7 +22,16 @@ const TIMESTAMP_PROPS: Partial<ComponentProps<typeof Timestamp>> = {
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<typeof Timestamp>['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 (
<header className='PanelHeader'>
<div className='PanelHeader__left'>{title}</div>
@@ -63,14 +76,37 @@ function PanelHeader({
</div>
)}
<div className='PanelHeader__timestamp'>
{Boolean(timestamp) && (
<Timestamp
value={new Date(timestamp)}
{...TIMESTAMP_PROPS}
/>
)}
{
Boolean(timestamp) && kind === 'draft' && (
<Timestamp
value={new Date(timestamp)}
{...TIMESTAMP_PROPS}
/>
)
}
{
Boolean(timestamp) && kind === 'scheduledPost' && (
<FormattedMessage
id='scheduled_post.panel.header.time'
defaultMessage='Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}'
values={{
scheduledDateTime: (
<Timestamp
value={timestamp}
ranges={SCHEDULED_POST_TIME_RANGES}
useSemanticOutput={false}
useTime={scheduledPostTimeFormat}
/>
),
isTodayOrTomorrow: isToday(timestampDateObject),
}}
/>
)
}
</div>
{!error && (
{kind === 'draft' && !error && (
<Tag
variant={'danger'}
uppercase={true}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
type Props = {
type: 'channel' | 'thread';
}
export default function PlaceholderScheduledPostsTitle({type}: Props) {
let title;
const icon = (
<i
className='icon icon-pencil-outline'
/>
);
if (type === 'thread') {
title = (
<FormattedMessage
id='scheduled_posts.row_title_thread.placeholder'
defaultMessage={'Thread to: {icon} No Destination'}
values={{
icon,
}}
/>
);
} else {
title = (
<FormattedMessage
id='scheduled_posts.row_title_channel.placeholder'
defaultMessage={'In: {icon} No Destination'}
values={{
icon,
}}
/>
);
}
return title;
}

View File

@@ -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 (
<svg
width='206'
height='167'
viewBox='0 0 206 167'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath='url(#clip0_4287_66385)'>
<path
d='M146.419 0.368244H46.4124C44.5797 0.362044 42.7637 0.716143 41.0681 1.41031C39.3725 2.10449 37.8305 3.12513 36.5302 4.41399C35.2299 5.70285 34.1967 7.23467 33.4896 8.922C32.7825 10.6093 32.4154 12.4191 32.4092 14.248V77.6732C32.4154 79.5021 32.7825 81.3119 33.4896 82.9992C34.1967 84.6866 35.2299 86.2184 36.5302 87.5072C37.8305 88.7961 39.3725 89.8168 41.0681 90.5109C42.7637 91.2051 44.5797 91.5592 46.4124 91.553H61.1713V115.295L83.3097 91.553H146.383C148.216 91.5592 150.032 91.2051 151.728 90.5109C153.423 89.8168 154.965 88.7961 156.266 87.5072C157.566 86.2184 158.599 84.6866 159.306 82.9992C160.013 81.3119 160.38 79.5021 160.387 77.6732V14.248C160.374 10.5605 158.897 7.02846 156.278 4.42651C153.66 1.82455 150.114 0.365103 146.419 0.368244Z'
fill='#FFBC1F'
/>
<path
d='M83.3096 91.553H146.383C148.216 91.5592 150.032 91.2051 151.728 90.5109C153.423 89.8167 154.965 88.7961 156.265 87.5072C157.566 86.2184 158.599 84.6865 159.306 82.9992C160.013 81.3119 160.38 79.5021 160.386 77.6732V39.168C160.386 39.168 155.982 74.7747 155.191 77.9088C154.4 81.043 152.83 85.7324 145.391 86.5101C137.953 87.2877 83.3096 91.553 83.3096 91.553Z'
fill='#CC8F00'
/>
<path
d='M62.2569 36.8936C64.0387 36.8936 65.7804 37.4208 67.2619 38.4086C68.7434 39.3965 69.8981 40.8005 70.58 42.4433C71.2618 44.086 71.4402 45.8936 71.0926 47.6375C70.745 49.3814 69.887 50.9833 68.6271 52.2405C67.3672 53.4978 65.762 54.354 64.0144 54.7009C62.2669 55.0478 60.4555 54.8698 58.8094 54.1893C57.1632 53.5089 55.7562 52.3566 54.7663 50.8782C53.7764 49.3998 53.2481 47.6617 53.2481 45.8836C53.2465 44.7026 53.4785 43.5328 53.9307 42.4414C54.3828 41.35 55.0464 40.3583 55.8832 39.5232C56.7201 38.6881 57.7138 38.026 58.8075 37.5747C59.9012 37.1235 61.0734 36.892 62.2569 36.8936Z'
fill='white'
/>
<path
d='M96.4161 36.8936C98.1979 36.8936 99.9396 37.4208 101.421 38.4086C102.903 39.3965 104.057 40.8005 104.739 42.4433C105.421 44.086 105.599 45.8936 105.252 47.6375C104.904 49.3814 104.046 50.9833 102.786 52.2405C101.526 53.4978 99.9212 54.354 98.1736 54.7009C96.4261 55.0478 94.6147 54.8698 92.9685 54.1893C91.3224 53.5089 89.9154 52.3566 88.9255 50.8782C87.9356 49.3998 87.4072 47.6617 87.4072 45.8836C87.4057 44.7026 87.6376 43.5328 88.0898 42.4414C88.542 41.35 89.2055 40.3583 90.0424 39.5232C90.8793 38.6881 91.873 38.026 92.9667 37.5747C94.0604 37.1235 95.2326 36.892 96.4161 36.8936Z'
fill='white'
/>
<path
d='M130.538 36.8936C132.32 36.8912 134.063 37.4165 135.546 38.4029C137.029 39.3893 138.186 40.7926 138.87 42.4351C139.553 44.0776 139.733 45.8856 139.387 47.6303C139.041 49.3749 138.184 50.9779 136.924 52.2364C135.665 53.4948 134.06 54.3522 132.312 54.7C130.564 55.0479 128.752 54.8705 127.105 54.1905C125.458 53.5104 124.051 52.3581 123.06 50.8794C122.07 49.4008 121.541 47.6622 121.541 45.8836C121.539 44.7036 121.771 43.5348 122.222 42.4442C122.674 41.3535 123.336 40.3623 124.172 39.5274C125.008 38.6924 126 38.0301 127.092 37.5781C128.185 37.1262 129.356 36.8936 130.538 36.8936Z'
fill='white'
/>
<path
d='M42.7877 26.9265C43.8039 23.3373 45.5215 19.9841 47.8417 17.06C50.162 14.136 53.0389 11.6989 56.3069 9.88902C56.5005 9.79136 56.6561 9.63239 56.7494 9.437C56.8427 9.2416 56.8685 9.02083 56.8225 8.80929C56.7766 8.59775 56.6617 8.40737 56.4957 8.26801C56.3298 8.12864 56.1222 8.04817 55.9055 8.03917C49.6005 7.66213 36.8724 9.00533 40.8632 26.8323C40.9053 27.0499 41.0193 27.2471 41.1871 27.3923C41.3548 27.5375 41.5666 27.6224 41.7884 27.6332C42.0102 27.6441 42.2293 27.5803 42.4105 27.4522C42.5917 27.3241 42.7245 27.139 42.7877 26.9265Z'
fill='#FFD470'
/>
<path
d='M143.323 125.892C133.808 125.702 125.916 122.444 119.649 116.118C113.382 109.793 110.104 101.914 109.816 92.4823C110.099 83.0454 113.377 75.1643 119.649 68.8388C125.921 62.5134 133.813 59.2554 143.323 59.0649C152.746 59.2603 160.591 62.5183 166.859 68.8388C173.126 75.1594 176.403 83.0405 176.691 92.4823C176.398 101.919 173.121 109.798 166.859 116.118C160.596 122.439 152.751 125.697 143.323 125.892Z'
fill='white'
/>
<path
d='M143.324 55.4165C153.815 55.6168 162.533 59.2166 169.478 66.2161C176.423 73.2156 180.042 81.9711 180.335 92.4825C180.042 102.984 176.423 111.737 169.478 118.742C162.533 125.746 153.815 129.346 143.324 129.541C132.74 129.341 123.985 125.741 117.059 118.742C110.134 111.742 106.517 102.989 106.21 92.4825C106.493 81.976 110.109 73.2205 117.059 66.2161C124.01 59.2118 132.764 55.6119 143.324 55.4165ZM121.449 70.6635C115.596 76.4516 112.585 83.7246 112.414 92.4825C112.604 101.231 115.616 108.501 121.449 114.294C127.282 120.087 134.566 123.128 143.302 123.416C151.949 123.123 159.187 120.082 165.015 114.294C170.844 108.506 173.853 101.236 174.043 92.4825C173.853 83.7295 170.844 76.4565 165.015 70.6635C159.187 64.8705 151.956 61.8299 143.324 61.5417C134.593 61.8347 127.302 64.8753 121.449 70.6635Z'
fill='#1E325C'
/>
<path
d='M142.875 95.2521C142.511 95.256 142.15 95.1871 141.812 95.0493C141.475 94.9115 141.169 94.7076 140.911 94.4497C140.654 94.1917 140.45 93.8848 140.312 93.547C140.175 93.2093 140.106 92.8474 140.11 92.4826V68.5461C140.098 68.1908 140.163 67.8371 140.3 67.5095C140.438 67.1818 140.645 66.8881 140.907 66.6484C141.428 66.1809 142.103 65.9224 142.802 65.9224C143.501 65.9224 144.176 66.1809 144.697 66.6484C144.96 66.8877 145.168 67.1812 145.307 67.5088C145.446 67.8364 145.512 68.1903 145.502 68.5461V92.4826C145.509 92.8477 145.442 93.2104 145.303 93.5482C145.165 93.8861 144.958 94.1917 144.697 94.4461C144.465 94.6989 144.184 94.901 143.87 95.0397C143.557 95.1783 143.218 95.2507 142.875 95.2521Z'
fill='#1E325C'
/>
<path
d='M161.76 84.0131C161.683 84.3559 161.532 84.6775 161.317 84.9549C161.102 85.2323 160.829 85.4586 160.516 85.6177L146.967 93.5012C146.766 94.2878 146.304 94.9828 145.657 95.4721C144.923 96.0238 144.021 96.3057 143.104 96.2708C142.616 96.2786 142.132 96.1857 141.681 95.9979C141.231 95.81 140.823 95.5313 140.485 95.1791C139.782 94.4567 139.389 93.4879 139.389 92.4792C139.389 91.4705 139.782 90.5017 140.485 89.7793C141.247 89.0315 142.258 88.5908 143.324 88.541C143.67 88.5499 144.013 88.5991 144.348 88.6876L157.751 80.9505C158.052 80.7639 158.389 80.6415 158.741 80.5911C159.092 80.5407 159.45 80.5633 159.792 80.6575C160.147 80.7378 160.482 80.8902 160.776 81.1054C161.07 81.3205 161.317 81.5938 161.502 81.9083C161.686 82.2228 161.804 82.5719 161.849 82.9339C161.893 83.2959 161.863 83.6632 161.76 84.0131Z'
fill='#1E325C'
/>
</g>
<defs>
<clipPath id='clip0_4287_66385'>
<rect
width='204.933'
height='204.933'
fill='white'
transform='translate(0.797852 -38.3296)'
/>
</clipPath>
</defs>
</svg>
);

View File

@@ -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<string>();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id)));
}, []);
return (
<div className='ScheduledPostList'>
{
scheduledPostsHasError &&
<AlertBanner
mode='danger'
className='scheduledPostListErrorIndicator'
message={
<FormattedMessage
id='scheduled_post.panel.error_indicator.message'
defaultMessage='One of your scheduled drafts cannot be sent.'
/>
}
/>
}
{
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 (
<DraftRow
key={scheduledPost.id}
item={scheduledPost}
displayName={displayName}
status={status}
user={user}
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
/>
);
})
}
{
scheduledPosts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={NoScheduledPostsIllustration}
title={formatMessage({
id: 'Schedule_post.empty_state.title',
defaultMessage: 'No scheduled drafts at the moment',
})}
subtitle={formatMessage({
id: 'Schedule_post.empty_state.subtitle',
defaultMessage: 'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
})}
/>
)
}
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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<ScheduledPostErrorCode>({
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();
}

View File

@@ -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<Post>) => Promise<Post>;
setDraft: (name: string, value: PostDraft | null) => void;
unsetEditingPost: () => void;
openModal: (input: ModalData<DialogProps>) => void;
scrollPostListToBottom: () => void;
runMessageWillBeUpdatedHooks: (newPost: Partial<Post>, oldPost: Post) => Promise<ActionResult>;
updateScheduledPost: (scheduledPost: ScheduledPost, connectionId: string) => Promise<ActionResult>;
}
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<string>(
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || '',
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || scheduledPost?.message || '',
);
const [selectionRange, setSelectionRange] = useState<State['selectionRange']>({start: editText.length, end: editText.length});
const caretPosition = useRef<number>(editText.length);
@@ -117,7 +132,8 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
const draftRef = useRef<PostDraft>(draft);
const saveDraftFrame = useRef<number|null>();
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,
}
<EditPostFooter
onSave={handleEdit}
onCancel={handleAutomatedRefocusAndExit}
onCancel={handleCancel}
/>
{postError && (
<div className={classNames('edit-post-footer', {'has-error': postError})}>

View File

@@ -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),
};
}

View File

@@ -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));
}
}
}

Some files were not shown because too many files have changed in this diff Show More