mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
203
api/v4/source/scheduled_post.yaml
Normal file
203
api/v4/source/scheduled_post.yaml
Normal 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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
43
server/channels/api4/post_utils.go
Normal file
43
server/channels/api4/post_utils.go
Normal 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
|
||||
}
|
||||
}
|
||||
225
server/channels/api4/scheduled_post.go
Normal file
225
server/channels/api4/scheduled_post.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
118
server/channels/app/post_permission_utils.go
Normal file
118
server/channels/app/post_permission_utils.go
Normal 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
|
||||
}
|
||||
112
server/channels/app/scheduled_post.go
Normal file
112
server/channels/app/scheduled_post.go
Normal 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
|
||||
}
|
||||
357
server/channels/app/scheduled_post_job.go
Normal file
357
server/channels/app/scheduled_post_job.go
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
211
server/channels/app/scheduled_post_job_test.go
Normal file
211
server/channels/app/scheduled_post_job_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
663
server/channels/app/scheduled_post_test.go
Normal file
663
server/channels/app/scheduled_post_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS scheduledposts;
|
||||
DROP INDEX IF EXISTS idx_scheduledposts_userid_channel_id_scheduled_at;
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
289
server/channels/store/sqlstore/scheduled_post_store.go
Normal file
289
server/channels/store/sqlstore/scheduled_post_store.go
Normal 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
|
||||
}
|
||||
14
server/channels/store/sqlstore/scheduled_post_store_test.go
Normal file
14
server/channels/store/sqlstore/scheduled_post_store_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
221
server/channels/store/storetest/mocks/ScheduledPostStore.go
Normal file
221
server/channels/store/storetest/mocks/ScheduledPostStore.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
472
server/channels/store/storetest/scheduled_post_store.go
Normal file
472
server/channels/store/storetest/scheduled_post_store.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
171
server/public/model/scheduled_post.go
Normal file
171
server/public/model/scheduled_post.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
ul#dropdown_send_post_options {
|
||||
li[role="menuitem"][aria-disabled="true"] {
|
||||
opacity: 1;
|
||||
|
||||
.label-elements {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.ScheduledPostActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -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 you’ve started will show here.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
webapp/channels/src/components/drafts/draft_row.scss
Normal file
8
webapp/channels/src/components/drafts/draft_row.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 you’ve 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
54
webapp/channels/src/components/drafts/utils.ts
Normal file
54
webapp/channels/src/components/drafts/utils.ts
Normal 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();
|
||||
}
|
||||
@@ -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})}>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
17
webapp/channels/src/components/edit_post/style.scss
Normal file
17
webapp/channels/src/components/edit_post/style.scss
Normal 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
Reference in New Issue
Block a user