Flag post API (#33765)

* Added enable/disable setting and feature flag

* added rest of notifgication settings

* Added backend for content flagging setting and populated notification values from server side defaults

* WIP user selector

* Added common reviewers UI

* Added additonal reviewers section

* WIP

* WIP

* Team table base

* Added search in teams

* Added search in teams

* Added additional settings section

* WIP

* Inbtegrated reviewers settings

* WIP

* WIP

* Added server side validation

* cleanup

* cleanup

* [skip ci]

* Some refactoring

* type fixes

* lint fix

* test: add content flagging settings test file

* test: add comprehensive unit tests for content flagging settings

* enhanced tests

* test: add test file for content flagging additional settings

* test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection

* Added additoonal settings test

* test: add empty test file for team reviewers section

* test: add comprehensive unit tests for TeamReviewersSection component

* test: update tests to handle async data fetching in team reviewers section

* test: add empty test file for content reviewers component

* feat: add comprehensive unit tests for ContentFlaggingContentReviewers component

* Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test

* test: add notification settings test file for content flagging

* test: add comprehensive unit tests for content flagging notification settings

* Added ContentFlaggingNotificationSettingsSection tests

* test: add user profile pill test file

* test: add comprehensive unit tests for UserProfilePill component

* refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests

* Added UserProfilePill tests

* test: add empty test file for content reviewers team option

* test: add comprehensive unit tests for TeamOptionComponent

* Added TeamOptionComponent tests

* test: add empty test file for reason_option component

* test: add comprehensive unit tests for ReasonOption component

* Added ReasonOption tests

* cleanup

* Fixed i18n error

* fixed e2e test lijnt issues

* Updated test cases

* Added snaoshot

* Updated snaoshot

* lint fix

* WIP

* lint fix

* Added post flagging properties setup

* review fixes

* updated snapshot

* CI

* Added base APIs

* Fetched team status data on load and team switch

* WIP

* Review fixes

* wip

* WIP

* Removed an test, updated comment

* CI

* Added tests

* Added tests

* Lint fix

* Added API specs

* Fixed types

* CI fixes

* API tests

* lint fixes

* Set env variable so API routes are regiustered

* Test update

* term renaming and disabling API tests on MySQL

* typo

* Updated store type definition

* Minor tweaks

* Added tests

* Removed error in app startup when content flaghging setup fails

* Updated sync condition:

* Flag message modal basE

* added post preview

* displaying options

* Adde comment input

* Updated tests and docs

* finction rename

* WIP

* Updated tests

* refactor

* lint fix

* MOved to data migration

* lint fix

* CI

* added new migration mocks

* Used setup for tests

* some comment

* Removed unnecesseery nil check

* Form validation

* WIP tests

* WIP tests

* WIP tests

* fix: mock content flagging config selector with correct reasons format

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* fix: add mock for getContentFlaggingConfig in flag post modal test

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

* Updated error code order in API docs

* removed empty files

* Added tests

* lint fixes

* minor tweak

* lint fix

* type fix

* fixed test

* nit

* test enhancements

* API WIP

* API WIP

* creating values

* creating content flagging channel and properties

* Able to save properties

* Added another property field

* WIP

* WIP

* Added validations

* Added data validations and hidden post if confifgured to

* lint fixes

* Added API spec

* Added some tests

* Added tests for getContentReviewBot

* test: add comprehensive tests for getContentReviewChannels function

* Added more app layer tests

* Added TestCanFlagPost

* test: Add comprehensive tests for FlagPost function

* Added all app layer tests

* Removed a file that was reamoved downstream

* test: add content flagging test file

* test: add comprehensive tests for FlagContentRequest.IsValid method

* Added model tests

* test: add comprehensive tests for SqlPropertyValueStore.CreateMany

* test: add comprehensive tests for flagPost() API function

* Added API tests

* linter fix

* WIP

* sent post flagging confirmation message

* fixed i18n nissues

* fixed i18n nissues

* CI

* Updated test

* fix: reset contentFlaggingGroupId for test isolation in content flagging tests

* removed cached group ID

* removed debug log

* review fixes

* Used correct ot name

* CI

* Updated mobile text

* Handled JSON error

* fixerdf i18n

* CI

* Integrate flag post api (#33798)

* WIP

* WIP

* Added API call

* test: add test for Client4.flagPost API call in FlagPostModal

* fix: remove userEvent.setup() from flag post modal test

* test: wrap submit button click in act for proper state updates

* Updated tests

* lint fix

* CI

* Updated to allow special characters in comments

* Handled empty comment

* Used finally

* CI

* Fixed test

* Spillage card integration (#33832)

* Created getContentFlaggingFields API

* created getPostPropertyValues API

* WIP

* Created useContentFlaggingFields hook

* WIP

* WIP

* Added option to retain data for reviewers

* Displayed deleted post's preview

* DIsplayed all properties

* Adding field name i18n

* WIP - managing i18n able texts

* Finished displaying all fields

* Manual cleanup

* lint fixes

* team role filter logic fix

* Fixed tests

* created new API to fetch flagged posts

* lint fix

* Added new client methods

* test: add comprehensive tests for content flagging APIs

* Added new API tests

* fixed openapi spec

* Fixed DataSpillageReport tests

* Fixed PostMarkdown test

* Fixed PostPreviewPropertyRenderer test

* Added metadata to card renderer

* test fixes

* Added no comment placeholder

* Fixed test

* refactor: improve test mocking for data spillage report component

* test mock updates

* Updated reducer

* not resetting mocks

* WIP

* review fixes

* CI

* Fixed

* fixes

* Content flagging actions implementation (#33852)

* Added view detail button

* Created RemoveFlaggedMessageConfirmationModal modal

* Added key and remove flag request modal

* IMplemented delete flagged post

* Handled edge cases of deleting flagged post

* keep message

* UI integration

* Added WS event for post report update and handled deleted files of flagged post

* Added error handling in keep/remove forms

* i18n fixes

* Updated OpenAPI specs

* fixed types

* fixed types

* refactoring

* Fixed tests

* review fixes

* Added new property translations

* Improved test

* fixed test

* CI

* fixes

* CI

* fixed a test

* CI

---------

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
This commit is contained in:
Harshil Sharma 2025-10-02 20:24:29 +05:30 committed by GitHub
parent 84cf95ff6e
commit c21ef29f02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 4943 additions and 629 deletions

View file

@ -33,6 +33,7 @@
summary: Get content flagging status for a team
description: |
Returns the content flagging status for a specific team, indicating whether content flagging is enabled on the specified team or not.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
@ -61,3 +62,197 @@
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/flag:
post:
summary: Flag a post
description: |
Flags a post with a reason and a comment. The user must have access to the channel to which the post belongs to.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to be flagged
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
reason:
type: string
description: The reason for flagging the post. This must be one of the configured reasons available for selection.
comment:
type: string
description: Comment from the user flagging the post.
responses:
"200":
description: Post flagged successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
'400':
description: Bad request - Invalid input data or missing required fields.
'403':
description: Forbidden - User does not have permission to flag this post.
'404':
description: Post not found or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/fields:
get:
summary: Get content flagging property fields
description: |
Returns the list of property fields that can be associated with content flagging reports. These fields are used for storing metadata about a post's flag.
An enterprise advanced license is required.
tags:
- Content Flagging
responses:
'200':
description: Custom fields retrieved successfully
content:
application/json:
schema:
type: object
description: A map of property field names to their definitions
additionalProperties:
$ref: "#/components/schemas/PropertyField"
'404':
description: Feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/field_values:
get:
summary: Get content flagging property field values for a post
description: |
Returns the property field values associated with content flagging reports for a specific post. These values provide additional context about the flags on the post.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to retrieve property field values for
responses:
'200':
description: Property field values retrieved successfully
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PropertyValue"
description: An array of property field values associated with the post
'403':
description: Forbidden - User does not have permission to access this post.
'404':
description: Post not found or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}:
get:
summary: Get a flagged post with all its content.
description: |
Returns the flagged post with all its data, even if it is soft-deleted. This endpoint is only accessible by content reviewers. A content reviewer can only fetch flagged posts from this API if the post is indeed flagged and they are a content reviewer of the post's team.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to retrieve
responses:
'200':
description: The flagged post is fetched correctly
content:
application/json:
schema:
$ref: "#/components/schemas/Post"
'403':
description: Forbidden - User does not have permission to access this post, or is not a reviewer of the post's team.
'404':
description: Post not found or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/remove:
put:
summary: Remove a flagged post
description: |
Permanently removes a flagged post and all its associated contents from the system. This action is typically performed by content reviewers after they have reviewed the flagged content. This action is irreversible.
The user must be a content reviewer of the team to which the post belongs to.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to be removed
responses:
'200':
description: Post removed successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
'403':
description: Forbidden - User does not have permission to remove this post.
'404':
description: Post not found or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/keep:
put:
summary: Keep a flagged post
description: |
Marks a flagged post as reviewed and keeps it in the system without any changes. This action is typically performed by content reviewers after they have reviewed the flagged content and determined that it does not violate any guidelines.
The user must be a content reviewer of the team to which the post belongs to.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the post to be kept
responses:
'200':
description: Post marked to be kept successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
'403':
description: Forbidden - User does not have permission to keep this post.
'404':
description: Post not found or feature is disabled via the feature flag.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.

View file

@ -452,6 +452,13 @@ func (th *TestHelper) TearDown() {
}
}
func (th *TestHelper) RemoveLicense() {
err := th.App.Srv().RemoveLicense()
if err != nil {
th.TestLogger.Warn("TestHelper.RemoveLicense: failed to remove license", mlog.Err(err))
}
}
func closeBody(r *http.Response) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)

View file

@ -6,6 +6,7 @@ package api4
import (
"encoding/json"
"net/http"
"slices"
"github.com/mattermost/mattermost/server/v8/channels/app"
@ -20,6 +21,12 @@ func (api *API) InitContentFlagging() {
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(getFlaggingConfiguration)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(getTeamPostFlaggingFeatureStatus)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(flagPost)).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(getContentFlaggingFields)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(getPostPropertyValues)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(getFlaggedPost)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(removeFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(keepFlaggedPost)).Methods(http.MethodPut)
}
func requireContentFlaggingEnabled(c *Context) {
@ -41,13 +48,40 @@ func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request
return
}
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings)
// A team ID is expected to be specified bny a content reviewer.
// When specified, we verify that the user is a content reviewer of the team.
// If the user is indeed a content reviewer, we return the configuration along with some extra fields
// that only a reviewer should be aware of.
// If no team ID is specified, we return the configuration as is, without the extra fields.
// This is the expected usage for non-reviewers.
teamId := r.URL.Query().Get("team_id")
asReviewer := false
if teamId != "" {
isReviewer, appErr := c.App.IsUserTeamContentReviewer(c.AppContext.Session().UserId, teamId)
if appErr != nil {
c.Err = appErr
return
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(config); err != nil {
mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err))
if !isReviewer {
c.Err = model.NewAppError("getFlaggingConfiguration", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return
}
asReviewer = true
}
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings, asReviewer)
responseBytes, err := json.Marshal(config)
if err != nil {
c.Err = model.NewAppError("getFlaggingConfiguration", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(responseBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) {
@ -72,16 +106,332 @@ func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http
payload := map[string]bool{
"enabled": enabled,
}
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(payload); err != nil {
mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err))
responseBytes, err := json.Marshal(payload)
if err != nil {
c.Err = model.NewAppError("getTeamPostFlaggingFeatureStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(responseBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings) *model.ContentFlaggingReportingConfig {
return &model.ContentFlaggingReportingConfig{
func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
var flagRequest model.FlagContentRequest
if err := json.NewDecoder(r.Body).Decode(&flagRequest); err != nil {
c.SetInvalidParamWithErr("flagPost", err)
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventFlagPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
enabled := app.ContentFlaggingEnabledForTeam(c.App.Config(), channel.TeamId)
if !enabled {
c.Err = model.NewAppError("flagPost", "api.content_flagging.error.not_available_on_team", nil, "", http.StatusBadRequest)
return
}
appErr = c.App.FlagPost(c.AppContext, post, channel.TeamId, userId, flagRequest)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("post")
writeOKResponse(w)
}
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings, asReviewer bool) *model.ContentFlaggingReportingConfig {
config := &model.ContentFlaggingReportingConfig{
Reasons: contentFlaggingSettings.AdditionalSettings.Reasons,
ReporterCommentRequired: contentFlaggingSettings.AdditionalSettings.ReporterCommentRequired,
ReviewerCommentRequired: contentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired,
}
if asReviewer {
config.NotifyReporterOnRemoval = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentRemoved], model.TargetReporter))
config.NotifyReporterOnDismissal = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentDismissed], model.TargetReporter))
}
return config
}
func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
groupId, appErr := c.App.ContentFlaggingGroupId()
if appErr != nil {
c.Err = appErr
return
}
mappedFields, appErr := c.App.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
c.Err = appErr
return
}
responseBytes, err := json.Marshal(mappedFields)
if err != nil {
c.Err = model.NewAppError("getContentFlaggingFields", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(responseBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// The requesting user must be a reviewer of the post's team
// to be able to fetch the post's Content Flagging property values
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
userId := c.AppContext.Session().UserId
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("getPostPropertyValues", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return
}
propertyValues, appErr := c.App.GetPostContentFlaggingPropertyValues(postId)
if appErr != nil {
c.Err = appErr
return
}
responseBytes, err := json.Marshal(propertyValues)
if err != nil {
c.Err = model.NewAppError("getPostPropertyValues", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(responseBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// A user can obtain a flagged post if-
// 1. The post is currently flagged and in any status
// 2. The user is a reviewer of the post's team
// check if user is a reviewer of the post's team
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("getFlaggedPost", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return
}
// This validates that the post is flagged
_, appErr = c.App.GetPostContentFlaggingStatusValue(postId)
if appErr != nil {
c.Err = appErr
return
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true})
post, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
if err := post.EncodeJSON(w); err != nil {
c.Err = model.NewAppError("getFlaggedPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
}
func removeFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "removeFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPermanentlyRemoveFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.PermanentDeleteFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "keepFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventKeepFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.KeepFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepRemoveFlaggedPostChecks(c *Context, r *http.Request) (*model.FlagContentActionRequest, string, *model.Post) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return nil, "", nil
}
c.RequirePostId()
if c.Err != nil {
return nil, "", nil
}
var actionRequest model.FlagContentActionRequest
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil {
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
return nil, "", nil
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, channel.TeamId)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
if !isReviewer {
c.Err = model.NewAppError("", "api.content_flagging.error.reviewer_only", nil, "", http.StatusForbidden)
return nil, "", nil
}
commentRequired := c.App.Config().ContentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired
if err := actionRequest.IsValid(*commentRequired); err != nil {
c.Err = err
return nil, "", nil
}
return &actionRequest, userId, post
}

View file

@ -27,6 +27,8 @@ func TestGetFlaggingConfiguration(t *testing.T) {
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
@ -40,6 +42,8 @@ func TestGetFlaggingConfiguration(t *testing.T) {
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
@ -52,6 +56,360 @@ func TestGetFlaggingConfiguration(t *testing.T) {
})
}
func TestGetPostPropertyValues(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging")
}()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should successfully get property values when user is a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
post := th.CreatePost()
response, err := client.FlagPostForContentReview(context.Background(), post.Id, &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
// Now get the property values
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, propertyValues)
require.Len(t, *propertyValues, 5)
})
}
func TestGetFlaggedPost(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging")
}()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
// Set up config so user is not a reviewer
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{}
(*config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting)[th.BasicTeam.Id] = model.TeamReviewerSetting{
Enabled: model.NewPointer(true),
ReviewerIds: &[]string{}, // Empty list - user is not a reviewer
}
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should successfully get flagged post when user is a reviewer and post is flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
post := th.CreatePost()
// First flag the post
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Now get the flagged post
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, flaggedPost)
require.Equal(t, post.Id, flaggedPost.Id)
})
}
func TestFlagPost(t *testing.T) {
mainHelper.Parallel(t)
os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true")
th := Setup(t).InitBasic()
defer func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging")
}()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), model.NewId(), flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Should return 403 when user does not have permission to view post", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
// Create a private channel and post
privateChannel := th.CreatePrivateChannel()
post := th.CreatePostWithClient(th.Client, privateChannel)
th.RemoveUserFromChannel(th.BasicUser, privateChannel)
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should return 400 when content flagging is not enabled for the team", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
// Set up config so content flagging is not enabled for this team
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{}
(*config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting)[th.BasicTeam.Id] = model.TeamReviewerSetting{Enabled: model.NewPointer(false)}
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should successfully flag a post when all conditions are met", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive data",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
mainHelper.Parallel(t)
@ -66,6 +424,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
@ -79,6 +439,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
@ -92,6 +454,8 @@ func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
t.Run("Should return Forbidden error when calling for a team without the team membership", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()

View file

@ -160,7 +160,7 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret())
rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, true, false, true)
rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
@ -454,7 +454,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, false, false, true)
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
@ -518,7 +518,7 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) {
continue
}
post = c.App.PreparePostForClient(c.AppContext, post, false, false, true)
post = c.App.PreparePostForClient(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
post.StripActionIntegrations()
posts = append(posts, post)
}

View file

@ -166,6 +166,10 @@ func (a *App) CreateBot(rctx request.CTX, bot *model.Bot) (*model.Bot, *model.Ap
}
func (a *App) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
return a.GetOrCreateSystemOwnedBot(rctx, model.BotSystemBotUsername, i18n.T("app.system.system_bot.bot_displayname"))
}
func (a *App) GetOrCreateSystemOwnedBot(rctx request.CTX, botUsername, botDisplayName string) (*model.Bot, *model.AppError) {
perPage := 1
userOptions := &model.UserGetOptions{
Page: 0,
@ -183,10 +187,9 @@ func (a *App) GetSystemBot(rctx request.CTX) (*model.Bot, *model.AppError) {
return nil, model.NewAppError("GetSystemBot", "app.bot.get_system_bot.empty_admin_list.app_error", nil, "", http.StatusInternalServerError)
}
T := i18n.GetUserTranslations(sysAdminList[0].Locale)
systemBot := &model.Bot{
Username: model.BotSystemBotUsername,
DisplayName: T("app.system.system_bot.bot_displayname"),
Username: botUsername,
DisplayName: botDisplayName,
Description: "",
OwnerId: sysAdminList[0].Id,
}

View file

@ -3,7 +3,28 @@
package app
import "github.com/mattermost/mattermost/server/public/model"
import (
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/pkg/errors"
)
const (
CONTENT_FLAGGING_MAX_PROPERTY_FIELDS = 100
CONTENT_FLAGGING_MAX_PROPERTY_VALUES = 100
POST_PROP_KEY_FLAGGED_POST_ID = "reported_post_id"
)
func ContentFlaggingEnabledForTeam(config *model.Config, teamId string) bool {
reviewerSettings := config.ContentFlaggingSettings.ReviewerSettings
@ -27,3 +48,665 @@ func ContentFlaggingEnabledForTeam(config *model.Config, teamId string) bool {
return hasAdditionalReviewers
}
func (a *App) FlagPost(rctx request.CTX, post *model.Post, teamId, reportingUserId string, flagData model.FlagContentRequest) *model.AppError {
commentBytes, err := json.Marshal(flagData.Comment)
if err != nil {
return model.NewAppError("FlagPost", "app.content_flagging.flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
reasonJson, err := json.Marshal(flagData.Reason)
if err != nil {
return model.NewAppError("FlagPost", "app.content_flagging.flag_post.marshal_reason.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
reasonJsonValue := json.RawMessage(reasonJson)
commentRequired := a.Config().ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired
validReasons := a.Config().ContentFlaggingSettings.AdditionalSettings.Reasons
if appErr := flagData.IsValid(*commentRequired, *validReasons); appErr != nil {
return appErr
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
reportingUser, appErr := a.GetUser(reportingUserId)
if appErr != nil {
return appErr
}
appErr = a.canFlagPost(groupId, post.Id, reportingUser.Locale)
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameStatus].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusPending)),
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reportingUserId)),
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingReason].ID,
Value: reasonJsonValue,
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
Value: commentJsonValue,
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
if err != nil {
return model.NewAppError("FlagPostForContentReview", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
_, appErr = a.DeletePost(rctx, post.Id, contentReviewBot.UserId)
if appErr != nil {
return model.NewAppError("FlagPostForContentReview", "app.content_flagging.delete_post.app_error", nil, appErr.Error(), http.StatusInternalServerError).Wrap(appErr)
}
}
a.Srv().Go(func() {
appErr = a.createContentReviewPost(rctx, post.Id, teamId, reportingUserId, flagData.Reason, post.ChannelId, post.UserId)
if appErr != nil {
rctx.Logger().Error("Failed to create content review post", mlog.Err(appErr), mlog.String("team_id", teamId), mlog.String("post_id", post.Id))
}
})
return a.sendContentFlaggingConfirmationMessage(rctx, reportingUserId, post.UserId, post.ChannelId)
}
func (a *App) ContentFlaggingGroupId() (string, *model.AppError) {
group, err := a.Srv().propertyService.GetPropertyGroup(model.ContentFlaggingGroupName)
if err != nil {
return "", model.NewAppError("getContentFlaggingGroupId", "app.content_flagging.get_group.error", nil, err.Error(), http.StatusInternalServerError)
}
return group.ID, nil
}
func (a *App) GetPostContentFlaggingStatusValue(postId string) (*model.PropertyValue, *model.AppError) {
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return nil, appErr
}
statusPropertyField, err := a.Srv().propertyService.GetPropertyFieldByName(groupId, "", contentFlaggingPropertyNameStatus)
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.get_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
searchOptions := model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES, FieldID: statusPropertyField.ID}
propertyValues, err := a.Srv().propertyService.SearchPropertyValues(groupId, searchOptions)
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.search_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(propertyValues) == 0 {
return nil, model.NewAppError("GetPostContentFlaggingStatusValue", "app.content_flagging.no_status_property.app_error", nil, "", http.StatusNotFound)
}
return propertyValues[0], nil
}
func (a *App) canFlagPost(groupId, postId, userLocal string) *model.AppError {
status, appErr := a.GetPostContentFlaggingStatusValue(postId)
if appErr != nil {
if appErr.StatusCode == http.StatusNotFound {
return nil
}
return appErr
}
var reason string
T := i18n.GetUserTranslations(userLocal)
switch strings.Trim(string(status.Value), `"`) {
case model.ContentFlaggingStatusPending, model.ContentFlaggingStatusAssigned:
reason = T("app.content_flagging.can_flag_post.in_progress")
case model.ContentFlaggingStatusRetained:
reason = T("app.content_flagging.can_flag_post.retained")
case model.ContentFlaggingStatusRemoved:
reason = T("app.content_flagging.can_flag_post.removed")
default:
reason = T("app.content_flagging.can_flag_post.unknown")
}
return model.NewAppError("canFlagPost", reason, nil, "", http.StatusBadRequest)
}
func (a *App) GetContentFlaggingMappedFields(groupId string) (map[string]*model.PropertyField, *model.AppError) {
fields, err := a.Srv().propertyService.SearchPropertyFields(groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS})
if err != nil {
return nil, model.NewAppError("GetContentFlaggingMappedFields", "app.content_flagging.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
mappedFields := map[string]*model.PropertyField{}
for _, field := range fields {
mappedFields[field.Name] = field
}
return mappedFields, nil
}
func (a *App) createContentReviewPost(rctx request.CTX, reportedPostId, teamId, reportingUserId, reportingReason, flaggedPostChannelId, flaggedPostAuthorId string) *model.AppError {
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
channels, appErr := a.getContentReviewChannels(rctx, teamId, contentReviewBot.UserId)
if appErr != nil {
return appErr
}
reportingUser, appErr := a.GetUser(reportingUserId)
if appErr != nil {
return appErr
}
flaggedPostChannel, appErr := a.GetChannel(rctx, flaggedPostChannelId)
if appErr != nil {
return appErr
}
flaggedPostTeam, appErr := a.GetTeam(flaggedPostChannel.TeamId)
if appErr != nil {
return appErr
}
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
if appErr != nil {
return appErr
}
message := fmt.Sprintf(
"@%s flagged a message for review.\n\nReason: %s\nChannel: ~%s\nTeam: %s\nPost Author: @%s\n\nOpen on a web browser or the Desktop app to view the full report and take action.",
reportingUser.Username,
reportingReason,
flaggedPostChannel.Name,
flaggedPostTeam.DisplayName,
flaggedPostAuthor.Username,
)
for _, channel := range channels {
post := &model.Post{
Message: message,
UserId: contentReviewBot.UserId,
Type: model.ContentFlaggingPostType,
ChannelId: channel.Id,
}
post.AddProp(POST_PROP_KEY_FLAGGED_POST_ID, reportedPostId)
_, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
rctx.Logger().Error("Failed to create content review post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId))
continue // Don't stop processing other channels if one fails
}
}
return nil
}
func (a *App) getContentReviewChannels(rctx request.CTX, teamId, contentReviewBotId string) ([]*model.Channel, *model.AppError) {
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
if appErr != nil {
return nil, appErr
}
var channels []*model.Channel
for _, userId := range reviewersUserIDs {
channel, appErr := a.GetOrCreateDirectChannel(rctx, userId, contentReviewBotId)
if appErr != nil {
// Don't stop processing other reviewers if one fails
rctx.Logger().Error("Failed to get or create direct channel for one of the reviewers and content review bot", mlog.Err(appErr), mlog.String("user_id", userId), mlog.String("bot_id", contentReviewBotId))
}
channels = append(channels, channel)
}
return channels, nil
}
func (a *App) getContentReviewBot(rctx request.CTX) (*model.Bot, *model.AppError) {
return a.GetOrCreateSystemOwnedBot(rctx, model.ContentFlaggingBotUsername, i18n.T("app.system.content_review_bot.bot_displayname"))
}
func (a *App) getReviewersForTeam(teamId string, includeAdditionalReviewers bool) ([]string, *model.AppError) {
reviewerUserIDMap := map[string]bool{}
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
if *reviewerSettings.CommonReviewers {
for _, userID := range *reviewerSettings.CommonReviewerIds {
reviewerUserIDMap[userID] = true
}
} else {
// If common reviewers are not enabled, we still need to check if the team has specific reviewers
teamSettings, exist := (*reviewerSettings.TeamReviewersSetting)[teamId]
if exist && *teamSettings.Enabled && teamSettings.ReviewerIds != nil {
for _, userID := range *teamSettings.ReviewerIds {
reviewerUserIDMap[userID] = true
}
}
}
if includeAdditionalReviewers {
var additionalReviewers []*model.User
if *reviewerSettings.TeamAdminsAsReviewers {
teamAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, nil, []string{model.TeamAdminRoleId})
if appErr != nil {
return nil, appErr
}
additionalReviewers = append(additionalReviewers, teamAdminReviewers...)
}
if *reviewerSettings.SystemAdminsAsReviewers {
sysAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, []string{model.SystemAdminRoleId}, nil)
if appErr != nil {
return nil, appErr
}
additionalReviewers = append(additionalReviewers, sysAdminReviewers...)
}
for _, user := range additionalReviewers {
reviewerUserIDMap[user.Id] = true
}
}
reviewerUserIDs := make([]string, 0, len(reviewerUserIDMap))
for userID := range maps.Keys(reviewerUserIDMap) {
reviewerUserIDs = append(reviewerUserIDs, userID)
}
return reviewerUserIDs, nil
}
func (a *App) getAllUsersInTeamForRoles(teamId string, systemRoles, teamRoles []string) ([]*model.User, *model.AppError) {
var additionalReviewers []*model.User
options := &model.UserGetOptions{
InTeamId: teamId,
Page: 0,
PerPage: 100,
Active: true,
Roles: systemRoles,
TeamRoles: teamRoles,
}
fetchFunc := func(page int) ([]*model.User, error) {
options.Page = page
users, appErr := a.GetUsersInTeam(options)
// Checking for error this way instead of directly returning *model.AppError
// doesn't equate to error == nil (pointer vs non-pointer)
if appErr != nil {
return users, errors.New(appErr.Error())
}
return users, nil
}
additionalReviewers, err := utils.Pager(fetchFunc, options.PerPage)
if err != nil {
return nil, model.NewAppError("getReviewersForTeam", "app.content_flagging.get_users_in_team.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
return additionalReviewers, nil
}
func (a *App) sendContentFlaggingConfirmationMessage(rctx request.CTX, flaggingUserId, flaggedPostAuthorId, channelID string) *model.AppError {
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
if appErr != nil {
return appErr
}
T := i18n.GetUserTranslations(flaggedPostAuthor.Locale)
post := &model.Post{
Message: T("app.content_flagging.flag_post_confirmation.message", map[string]any{"username": flaggedPostAuthor.Username}),
ChannelId: channelID,
}
a.SendEphemeralPost(rctx, flaggingUserId, post)
return nil
}
func (a *App) IsUserTeamContentReviewer(userId, teamId string) (bool, *model.AppError) {
// not fetching additional reviewers because if the user exist in common or team
// specific reviewers, they are definitely a reviewer, and it saves multiple database calls.
reviewers, appErr := a.getReviewersForTeam(teamId, false)
if appErr != nil {
return false, appErr
}
if slices.Contains(reviewers, userId) {
return true, nil
}
// if user is not in common or team specific reviewers, we need to check if they are
// an additional reviewer.
reviewers, appErr = a.getReviewersForTeam(teamId, true)
if appErr != nil {
return false, appErr
}
return slices.Contains(reviewers, userId), nil
}
func (a *App) GetPostContentFlaggingPropertyValues(postId string) ([]*model.PropertyValue, *model.AppError) {
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return nil, appErr
}
propertyValues, err := a.Srv().propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES})
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.content_flagging.search_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return propertyValues, nil
}
func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
// when a flagged post is removed, the following things need to be done
// 1. Hard delete corresponding file infos
// 2. Hard delete file infos associated to post's edit history
// 3. Hard delete post's edit history
// 4. Hard delete the files from file storage
// 5. Hard delete post's priority data
// 6. Hard delete post's post acknowledgements
// 7. Hard delete post reminders
// 8. Scrub the post's content - message, props
commentBytes, jsonErr := json.Marshal(actionRequest.Comment)
if jsonErr != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
status, appErr := a.GetPostContentFlaggingStatusValue(flaggedPost.Id)
if appErr != nil {
return appErr
}
statusValue := strings.Trim(string(status.Value), `"`)
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
return model.NewAppError("removeFlaggedPost", "api.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
}
editHistories, appErr := a.GetEditHistoryForPost(flaggedPost.Id)
if appErr != nil {
//editHistories = []*model.Post{}
if appErr.StatusCode != http.StatusNotFound {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to get edit history for flaggedPost", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
}
}
for _, editHistory := range editHistories {
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, editHistory.Id); filesDeleteAppErr != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for one of the edit history posts", mlog.Err(filesDeleteAppErr), mlog.String("post_id", editHistory.Id))
}
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, reviewerId); deletePostAppErr != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
}
}
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, flaggedPost.Id); filesDeleteAppErr != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for the flaggedPost", mlog.Err(filesDeleteAppErr), mlog.String("post_id", flaggedPost.Id))
}
if err := a.DeletePriorityForPost(flaggedPost.Id); err != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost priority for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
if err := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(flaggedPost.Id); err != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost acknowledgements for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
if err := a.Srv().Store().Post().DeleteAllPostRemindersForPost(flaggedPost.Id); err != nil {
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost reminders for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
scrubPost(flaggedPost)
_, err := a.Srv().Store().Post().Overwrite(rctx, flaggedPost)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.update_property_value.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
a.Srv().Go(func() {
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
return
}
propertyValues = append(propertyValues, status)
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
rctx.Logger().Error("Failed to publish report change after permanently removing flagged post", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
})
return nil
}
func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
// for keeping a flagged flaggedPost we need to-
// 1. Undelete the flaggedPost if it was deleted, that's it
status, appErr := a.GetPostContentFlaggingStatusValue(flaggedPost.Id)
if appErr != nil {
return appErr
}
statusValue := strings.Trim(string(status.Value), `"`)
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
return model.NewAppError("removeFlaggedPost", "api.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
}
if flaggedPost.DeleteAt > 0 {
flaggedPost.DeleteAt = 0
flaggedPost.UpdateAt = model.GetMillis()
flaggedPost.PreCommit()
_, err := a.Srv().Store().Post().Overwrite(rctx, flaggedPost)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.keep_post.undelete.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
err = a.Srv().Store().FileInfo().RestoreForPostByIds(rctx, flaggedPost.Id, flaggedPost.FileIds)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.restore_file_info.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
commentBytes, err := json.Marshal(actionRequest.Comment)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.keep_flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
_, err = a.Srv().propertyService.CreatePropertyValues(propertyValues)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.keep_post.status_update.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
a.Srv().Go(func() {
channel, getChannelErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if getChannelErr != nil {
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(getChannelErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
return
}
propertyValues = append(propertyValues, status)
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
rctx.Logger().Error("Failed to publish report change after permanently removing flagged flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
})
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", flaggedPost.ChannelId, "", nil, "")
appErr = a.publishWebsocketEventForPost(rctx, flaggedPost, message)
if appErr != nil {
rctx.Logger().Error("Failed to publish websocket event for post edit while keeping flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
}
a.invalidateCacheForChannelPosts(flaggedPost.ChannelId)
return nil
}
func scrubPost(post *model.Post) {
post.Message = "*Content deleted as part of Content Flagging review process*"
post.MessageSource = post.Message
post.Hashtags = ""
post.Metadata = nil
post.FileIds = []string{}
post.SetProps(make(map[string]any))
}
func (a *App) publishContentFlaggingReportUpdateEvent(targetId, teamId string, propertyValues []*model.PropertyValue) *model.AppError {
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
if appErr != nil {
return appErr
}
bytes, err := json.Marshal(propertyValues)
if err != nil {
return model.NewAppError("publishContentFlaggingReportUpdateEvent", "app.content_flagging.marshal_property_values.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
}
for _, userId := range reviewersUserIDs {
message := model.NewWebSocketEvent(model.WebsocketContentFlaggingReportValueUpdated, "", "", userId, nil, "")
message.Add("property_values", string(bytes))
message.Add("target_id", targetId)
a.Publish(message)
}
return nil
}

View file

@ -4,21 +4,26 @@
package app
import (
"encoding/json"
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestContentFlaggingEnabledForTeam(t *testing.T) {
getBaseConfig := func() *model.Config {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
var getBaseConfig = func() *model.Config {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
return &model.Config{
ContentFlaggingSettings: contentFlaggingSettings,
}
return &model.Config{
ContentFlaggingSettings: contentFlaggingSettings,
}
}
func TestContentFlaggingEnabledForTeam(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should return true for common reviewers", func(t *testing.T) {
config := getBaseConfig()
config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
@ -70,3 +75,748 @@ func TestContentFlaggingEnabledForTeam(t *testing.T) {
require.True(t, status)
})
}
func TestGetContentReviewChannels(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
baseConfig := model.ContentFlaggingSettings{}
baseConfig.SetDefaults()
baseConfig.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
baseConfig.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
baseConfig.ReviewerSettings.CommonReviewers = model.NewPointer(true)
baseConfig.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.BasicUser2.Id}
t.Run("should return channels for common reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
})
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 2)
for _, channel := range channels {
require.Equal(t, model.ChannelTypeDirect, channel.Type)
otherUserId := channel.GetOtherUserIdForDM(contentReviewBot.UserId)
require.True(t, otherUserId == th.BasicUser.Id || otherUserId == th.BasicUser2.Id)
}
})
t.Run("should return channels for system admins as additional reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
})
// Sysadmin explicitly need to be a team member to be returned as reviewer
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
require.Nil(t, appErr)
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 3)
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
require.Equal(t, model.ChannelTypeDirect, channels[1].Type)
require.Equal(t, model.ChannelTypeDirect, channels[2].Type)
reviewerIds := []string{
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
channels[2].GetOtherUserIdForDM(contentReviewBot.UserId),
}
require.Contains(t, reviewerIds, th.BasicUser.Id)
require.Contains(t, reviewerIds, th.BasicUser2.Id)
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
})
t.Run("should return channels for team admins as additional reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
})
// Create a new user and make them team admin
teamAdmin := th.CreateUser()
defer func() {
_ = th.App.PermanentDeleteUser(th.Context, teamAdmin)
}()
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId)
require.Nil(t, appErr)
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 3)
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
require.Equal(t, model.ChannelTypeDirect, channels[1].Type)
require.Equal(t, model.ChannelTypeDirect, channels[2].Type)
reviewerIds := []string{
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
channels[2].GetOtherUserIdForDM(contentReviewBot.UserId),
}
require.Contains(t, reviewerIds, th.BasicUser.Id)
require.Contains(t, reviewerIds, th.BasicUser2.Id)
require.Contains(t, reviewerIds, teamAdmin.Id)
})
t.Run("should return channels for team reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
},
}
})
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 1)
require.Equal(t, model.ChannelTypeDirect, channels[0].Type)
otherUserId := channels[0].GetOtherUserIdForDM(contentReviewBot.UserId)
require.Equal(t, th.BasicUser2.Id, otherUserId)
})
t.Run("should not return channels for team reviewers when disabled for the team", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(false),
ReviewerIds: model.NewPointer([]string{th.BasicUser.Id}),
},
}
})
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 0)
})
t.Run("should return channels for additional reviewers with team reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
},
}
})
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
defer func() {
_ = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
}()
require.Nil(t, appErr)
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
require.NotNil(t, contentReviewBot)
channels, appErr := th.App.getContentReviewChannels(th.Context, th.BasicTeam.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
require.Len(t, channels, 2)
reviewerIds := []string{
channels[0].GetOtherUserIdForDM(contentReviewBot.UserId),
channels[1].GetOtherUserIdForDM(contentReviewBot.UserId),
}
require.Contains(t, reviewerIds, th.BasicUser2.Id)
require.Contains(t, reviewerIds, th.SystemAdminUser.Id)
})
}
func TestGetReviewersForTeam(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
t.Run("should return common reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.BasicUser2.Id}
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
})
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 2)
require.Contains(t, reviewers, th.BasicUser.Id)
require.Contains(t, reviewers, th.BasicUser2.Id)
})
t.Run("should return system admins as additional reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
})
// Sysadmin explicitly need to be a team member to be returned as reviewer
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 2)
require.Contains(t, reviewers, th.BasicUser.Id)
require.Contains(t, reviewers, th.SystemAdminUser.Id)
// system admin is a reviewer even when there are no common reviewers
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{}
})
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Contains(t, reviewers, th.SystemAdminUser.Id)
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
// If sysadmin is not a team member, they should not be returned as a reviewer
appErr = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Contains(t, reviewers, th.BasicUser.Id)
})
t.Run("should return team admins as additional reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
})
// Create a new user and make them team admin
teamAdmin := th.CreateUser()
defer func() {
_ = th.App.PermanentDeleteUser(th.Context, teamAdmin)
}()
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId)
require.Nil(t, appErr)
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 2)
require.Contains(t, reviewers, th.BasicUser.Id)
require.Contains(t, reviewers, teamAdmin.Id)
// team admin is a reviewer even when there are no common reviewers
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{}
})
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Contains(t, reviewers, teamAdmin.Id)
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
})
// If team admin is not a team member, they should not be returned as a reviewer
appErr = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "")
require.Nil(t, appErr)
reviewers, appErr = th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Contains(t, reviewers, th.BasicUser.Id)
})
t.Run("should return team reviewers", func(t *testing.T) {
team2 := th.CreateTeam()
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
},
}
})
// Reviewers configured for th.BasicTeam
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 1)
require.Contains(t, reviewers, th.BasicUser2.Id)
// NO reviewers configured for team2
reviewers, appErr = th.App.getReviewersForTeam(team2.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 0)
})
t.Run("should not return reviewers when disabled for the team", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(false),
ReviewerIds: model.NewPointer([]string{th.BasicUser.Id}),
},
}
})
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 0)
})
t.Run("should return additional reviewers with team reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false)
conf.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: model.NewPointer([]string{th.BasicUser2.Id}),
},
}
})
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 2)
require.Contains(t, reviewers, th.BasicUser2.Id)
require.Contains(t, reviewers, th.SystemAdminUser.Id)
})
t.Run("should return unique reviewers", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
contentFlaggingSettings := model.ContentFlaggingSettings{}
contentFlaggingSettings.SetDefaults()
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true)
conf.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id, th.SystemAdminUser.Id}
conf.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true)
})
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.SystemAdminUser.Id, "")
require.Nil(t, appErr)
reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true)
require.Nil(t, appErr)
require.Len(t, reviewers, 2)
require.Contains(t, reviewers, th.BasicUser.Id)
require.Contains(t, reviewers, th.SystemAdminUser.Id)
})
}
func TestCanFlagPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
t.Run("should be able to flag post which has not already been flagged", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
appErr = th.App.canFlagPost(groupId, post.Id, "en")
require.Nil(t, appErr)
})
t.Run("should not be able to flag post which has already been flagged", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
statusField, err := th.Server.propertyService.GetPropertyFieldByName(groupId, "", contentFlaggingPropertyNameStatus)
require.NoError(t, err)
propertyValue, err := th.Server.propertyService.CreatePropertyValue(&model.PropertyValue{
TargetID: post.Id,
GroupID: groupId,
FieldID: statusField.ID,
TargetType: "post",
Value: json.RawMessage(`"` + model.ContentFlaggingStatusPending + `"`),
})
require.NoError(t, err)
// Can't fleg when post already flagged in pending status
appErr = th.App.canFlagPost(groupId, post.Id, "en")
require.NotNil(t, appErr)
require.Equal(t, "Cannot flag this post as is already flagged.", appErr.Id)
// Can't fleg when post already flagged in assigned status
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusAssigned + `"`)
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
require.NoError(t, err)
appErr = th.App.canFlagPost(groupId, post.Id, "en")
require.NotNil(t, appErr)
// Can't fleg when post already flagged in retained status
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusRetained + `"`)
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
require.NoError(t, err)
appErr = th.App.canFlagPost(groupId, post.Id, "en")
require.NotNil(t, appErr)
// Can't fleg when post already flagged in removed status
propertyValue.Value = json.RawMessage(`"` + model.ContentFlaggingStatusRemoved + `"`)
_, err = th.Server.propertyService.UpdatePropertyValue(groupId, propertyValue)
require.NoError(t, err)
appErr = th.App.canFlagPost(groupId, post.Id, "en")
require.NotNil(t, appErr)
})
}
func TestFlagPost(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
// Setup base config for content flagging
baseConfig := model.ContentFlaggingSettings{}
baseConfig.SetDefaults()
baseConfig.ReviewerSettings.CommonReviewers = model.NewPointer(true)
baseConfig.ReviewerSettings.CommonReviewerIds = &[]string{th.BasicUser.Id}
baseConfig.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
baseConfig.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
baseConfig.AdditionalSettings.Reasons = &[]string{"spam", "harassment", "inappropriate"}
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings = baseConfig
})
t.Run("should successfully flag a post with valid data", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
// Verify property values were created
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
// Check status property
statusValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameStatus].ID,
})
require.NoError(t, err)
require.Len(t, statusValues, 1)
require.Equal(t, `"`+model.ContentFlaggingStatusPending+`"`, string(statusValues[0].Value))
// Check reporting user property
userValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReportingUserID].ID,
})
require.NoError(t, err)
require.Len(t, userValues, 1)
require.Equal(t, `"`+th.BasicUser2.Id+`"`, string(userValues[0].Value))
// Check reason property
reasonValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReportingReason].ID,
})
require.NoError(t, err)
require.Len(t, reasonValues, 1)
require.Equal(t, `"spam"`, string(reasonValues[0].Value))
// Check comment property
commentValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
})
require.NoError(t, err)
require.Len(t, commentValues, 1)
require.Equal(t, `"This is spam content"`, string(commentValues[0].Value))
})
t.Run("should fail with invalid reason", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "invalid_reason",
Comment: "This is spam content",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.NotNil(t, appErr)
require.Equal(t, "api.content_flagging.error.reason_invalid", appErr.Id)
})
t.Run("should fail when comment is required but not provided", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired = model.NewPointer(true)
})
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.NotNil(t, appErr)
// Reset config
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired = model.NewPointer(false)
})
})
t.Run("should fail when trying to flag already flagged post", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "\"This is spam content\"",
}
// Flag the post first time
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
// Try to flag the same post again
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.NotNil(t, appErr)
require.Equal(t, "Cannot flag this post as is already flagged.", appErr.Id)
})
t.Run("should hide flagged content when configured", func(t *testing.T) {
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent = model.NewPointer(true)
})
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "\"This is spam content\"",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
// Verify post was deleted
deletedPost, appErr := th.App.GetSinglePost(th.Context, post.Id, false)
require.NotNil(t, appErr)
require.Nil(t, deletedPost)
// Reset config
th.UpdateConfig(func(conf *model.Config) {
conf.ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent = model.NewPointer(false)
})
})
t.Run("should create content review post for reviewers", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "harassment",
Comment: "\"This is harassment\"",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
// The reviewer posts are created async in a go routine. Wait for a short time to allow it to complete.
// 2 seconds is the minimum time when the test consistently passes locally and in CI.
time.Sleep(5 * time.Second)
// Get the content review bot
contentReviewBot, appErr := th.App.getContentReviewBot(th.Context)
require.Nil(t, appErr)
// Get direct channel between reviewer and bot
dmChannel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, contentReviewBot.UserId)
require.Nil(t, appErr)
// Check if review post was created in the DM channel
posts, appErr := th.App.GetPostsPage(th.Context, model.GetPostsOptions{
ChannelId: dmChannel.Id,
Page: 0,
PerPage: 10,
})
require.Nil(t, appErr)
require.NotEmpty(t, posts.Posts)
// Find the content review post
var reviewPost *model.Post
for _, p := range posts.Posts {
if p.Type == "custom_spillage_report" {
reviewPost = p
break
}
}
require.NotNil(t, reviewPost)
})
t.Run("should work with empty comment when not required", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "inappropriate",
Comment: "",
}
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
// Verify property values were created with empty comment
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
commentValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
})
require.NoError(t, err)
require.Len(t, commentValues, 1)
require.Equal(t, `""`, string(commentValues[0].Value))
})
t.Run("should set reporting time property", func(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "\"Test comment\"",
}
beforeTime := model.GetMillis()
appErr := th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
afterTime := model.GetMillis()
require.Nil(t, appErr)
// Verify reporting time property was set
groupId, appErr := th.App.ContentFlaggingGroupId()
require.Nil(t, appErr)
mappedFields, appErr := th.App.GetContentFlaggingMappedFields(groupId)
require.Nil(t, appErr)
timeValues, err := th.Server.propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{
TargetIDs: []string{post.Id},
PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES,
FieldID: mappedFields[contentFlaggingPropertyNameReportingTime].ID,
})
require.NoError(t, err)
require.Len(t, timeValues, 1)
var reportingTime int64
err = json.Unmarshal(timeValues[0].Value, &reportingTime)
require.NoError(t, err)
require.True(t, reportingTime >= beforeTime && reportingTime <= afterTime)
})
}

View file

@ -271,7 +271,7 @@ func (a *App) PatchCPAValues(userID string, fieldValueMap map[string]json.RawMes
value := &model.PropertyValue{
GroupID: groupID,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
TargetID: userID,
FieldID: fieldID,
Value: sanitizedValue,

View file

@ -583,7 +583,7 @@ func TestDeleteCPAField(t *testing.T) {
for i := range 3 {
newValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaGroupID,
FieldID: createdField.ID,
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),
@ -662,7 +662,7 @@ func TestGetCPAValue(t *testing.T) {
t.Run("should fail if the group id is invalid", func(t *testing.T) {
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: model.NewId(),
FieldID: fieldID,
Value: json.RawMessage(`"Value"`),
@ -678,7 +678,7 @@ func TestGetCPAValue(t *testing.T) {
t.Run("should succeed if id exists", func(t *testing.T) {
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaGroupID,
FieldID: fieldID,
Value: json.RawMessage(`"Value"`),
@ -702,7 +702,7 @@ func TestGetCPAValue(t *testing.T) {
propertyValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaGroupID,
FieldID: createdField.ID,
Value: json.RawMessage(`["option1", "option2", "option3"]`),
@ -751,7 +751,7 @@ func TestListCPAValues(t *testing.T) {
value := &model.PropertyValue{
TargetID: userID,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: cpaGroupID,
FieldID: field.ID,
Value: json.RawMessage(fmt.Sprintf(`"Value %d"`, i)),

View file

@ -28,7 +28,7 @@ const (
remainingSchemaMigrationsKey = "RemainingSchemaMigrations"
postPriorityConfigDefaultTrueMigrationKey = "PostPriorityConfigDefaultTrueMigrationComplete"
contentFlaggingSetupDoneKey = "content_flagging_setup_done"
contentFlaggingMigrationVersion = "v1"
contentFlaggingMigrationVersion = "v2"
contentFlaggingPropertyNameFlaggedPostId = "flagged_post_id"
contentFlaggingPropertyNameStatus = "status"
@ -40,6 +40,8 @@ const (
contentFlaggingPropertyNameActorUserID = "actor_user_id"
contentFlaggingPropertyNameActorComment = "actor_comment"
contentFlaggingPropertyNameActionTime = "action_time"
contentFlaggingPropertySubTypeTimestamp = "timestamp"
)
// This function migrates the default built in roles from code/config to the database.
@ -641,6 +643,14 @@ func (s *Server) doSetupContentFlaggingProperties() error {
GroupID: group.ID,
Name: contentFlaggingPropertyNameStatus,
Type: model.PropertyFieldTypeSelect,
Attrs: map[string]any{
"options": []map[string]string{
{"name": model.ContentFlaggingStatusPending, "color": "light_grey"},
{"name": model.ContentFlaggingStatusAssigned, "color": "dark_blue"},
{"name": model.ContentFlaggingStatusRemoved, "color": "dark_red"},
{"name": model.ContentFlaggingStatusRetained, "color": "light_blue"},
},
},
},
contentFlaggingPropertyNameReportingUserID: {
GroupID: group.ID,
@ -661,6 +671,7 @@ func (s *Server) doSetupContentFlaggingProperties() error {
GroupID: group.ID,
Name: contentFlaggingPropertyNameReportingTime,
Type: model.PropertyFieldTypeText,
Attrs: map[string]any{"subType": contentFlaggingPropertySubTypeTimestamp},
},
contentFlaggingPropertyNameReviewerUserID: {
GroupID: group.ID,

View file

@ -381,7 +381,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan
// to be done when we send the post over the websocket in handlePostEvents
// PS: we don't want to include PostPriority from the db to avoid the replica lag,
// so we just return the one that was passed with post
rpost = a.PreparePostForClient(rctx, rpost, true, false, false)
rpost = a.PreparePostForClient(rctx, rpost, &model.PreparePostForClientOpts{IsEditPost: true})
a.applyPostWillBeConsumedHook(&rpost)
@ -584,7 +584,7 @@ func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Pos
post.GenerateActionIds()
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", post.ChannelId, userID, nil, "")
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, true, false, true)
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID)
@ -618,7 +618,7 @@ func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.P
post.GenerateActionIds()
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, userID, nil, "")
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, true, false, true)
post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
post = model.AddPostActionCookies(post, a.PostActionCookieSecret())
sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID)
@ -788,7 +788,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda
}, plugin.MessageHasBeenUpdatedID)
})
rpost = a.PreparePostForClientWithEmbedsAndImages(rctx, rpost, false, true, true)
rpost = a.PreparePostForClientWithEmbedsAndImages(rctx, rpost, &model.PreparePostForClientOpts{IsEditPost: true, IncludePriority: true})
// Ensure IsFollowing is nil since this updated post will be broadcast to all users
// and we don't want to have to populate it for every single user and broadcast to each
@ -2230,7 +2230,7 @@ func (a *App) SetPostReminder(rctx request.CTX, postID, userID string, targetTim
}
message := model.NewWebSocketEvent(model.WebsocketEventEphemeralMessage, "", ephemeralPost.ChannelId, userID, nil, "")
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(rctx, ephemeralPost, true, false, true)
ephemeralPost = a.PreparePostForClientWithEmbedsAndImages(rctx, ephemeralPost, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
ephemeralPost = model.AddPostActionCookies(ephemeralPost, a.PostActionCookieSecret())
postJSON, jsonErr := ephemeralPost.ToJSON()

View file

@ -345,7 +345,7 @@ func (a *App) sendPostUpdateEvent(rctx request.CTX, post *model.Post) {
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", post.ChannelId, "", nil, "")
// Prepare the post with metadata for the event
preparedPost := a.PreparePostForClient(rctx, post, false, true, true)
preparedPost := a.PreparePostForClient(rctx, post, &model.PreparePostForClientOpts{IsEditPost: true, IncludePriority: true})
if appErr := a.publishWebsocketEventForPost(rctx, preparedPost, message); appErr != nil {
rctx.Logger().Warn("Failed to send post update event for acknowledgement sync", mlog.String("post_id", post.Id), mlog.Err(appErr))

View file

@ -64,7 +64,7 @@ func (a *App) PreparePostListForClient(rctx request.CTX, originalList *model.Pos
}
for id, originalPost := range originalList.Posts {
post := a.PreparePostForClientWithEmbedsAndImages(rctx, originalPost, false, false, false)
post := a.PreparePostForClientWithEmbedsAndImages(rctx, originalPost, &model.PreparePostForClientOpts{})
list.Posts[id] = post
}
@ -112,7 +112,7 @@ func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
}
}
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post {
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post := originalPost.Clone()
// Proxy image links before constructing metadata so that requests go through the proxy
@ -123,7 +123,7 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
post.Metadata = &model.PostMetadata{}
}
if post.DeleteAt > 0 {
if post.DeleteAt > 0 && !opts.RetainContent {
// For deleted posts we don't fill out metadata nor do we return the post content
post.Message = ""
post.Metadata = &model.PostMetadata{}
@ -139,13 +139,13 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
}
// Files
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, isNewPost || isEditPost); err != nil {
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, opts.IsNewPost || opts.IsEditPost, opts.IncludeDeleted); err != nil {
rctx.Logger().Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
post.Metadata.Files = fileInfos
}
if includePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
if opts.IncludePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
// Post's Priority if any
if priority, err := a.GetPriorityForPost(post.Id); err != nil {
rctx.Logger().Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err))
@ -164,9 +164,9 @@ func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, i
return post
}
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, isNewPost, isEditPost, includePriority bool) *model.Post {
post := a.PreparePostForClient(rctx, originalPost, isNewPost, isEditPost, includePriority)
post = a.getEmbedsAndImages(rctx, post, isNewPost)
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post := a.PreparePostForClient(rctx, originalPost, opts)
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
return post
}
@ -274,12 +274,12 @@ func (a *App) SanitizePostListMetadataForUser(rctx request.CTX, postList *model.
return clonedPostList, nil
}
func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster bool) ([]*model.FileInfo, int64, *model.AppError) {
func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) {
if len(post.FileIds) == 0 {
return nil, 0, nil
}
return a.GetFileInfosForPost(rctx, post.Id, fromMaster, false)
return a.GetFileInfosForPost(rctx, post.Id, fromMaster, includeDeleted)
}
func (a *App) getEmojisAndReactionsForPost(rctx request.CTX, post *model.Post) ([]*model.Emoji, []*model.Reaction, *model.AppError) {
@ -701,7 +701,7 @@ func (a *App) getLinkMetadataForPermalink(rctx request.CTX, requestURL string) (
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)}
} else {
// referencedPost does not contain a permalink: we get its metadata
referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(rctx, referencedPost, false, false, false)
referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(rctx, referencedPost, &model.PreparePostForClientOpts{})
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)}
}

View file

@ -136,7 +136,7 @@ func TestPreparePostForClient(t *testing.T) {
Message: message,
}
clientPost := th.App.PreparePostForClient(th.Context, post, false, true, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsEditPost: true})
t.Run("doesn't mutate provided post", func(t *testing.T) {
assert.NotEqual(t, clientPost, post, "should've returned a new post")
@ -162,7 +162,7 @@ func TestPreparePostForClient(t *testing.T) {
post := th.CreatePost(th.BasicChannel)
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
assert.False(t, clientPost == post, "should've returned a new post")
assert.Equal(t, clientPost, post, "shouldn't have changed any metadata")
@ -179,7 +179,7 @@ func TestPreparePostForClient(t *testing.T) {
reactions := []*model.Reaction{reaction1, reaction2, reaction3}
post.HasReactions = true
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
assert.Len(t, clientPost.Metadata.Reactions, 3, "should've populated Reactions")
assert.ElementsMatch(t, reactions, clientPost.Metadata.Reactions)
@ -205,7 +205,7 @@ func TestPreparePostForClient(t *testing.T) {
var clientPost *model.Post
assert.Eventually(t, func() bool {
clientPost = th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
return assert.ObjectsAreEqual([]*model.FileInfo{fileInfo}, clientPost.Metadata.Files)
}, time.Second, 10*time.Millisecond)
@ -241,7 +241,7 @@ func TestPreparePostForClient(t *testing.T) {
th.AddReactionToPost(post, th.BasicUser2, "angry")
post.HasReactions = true
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
t.Run("populates emojis", func(t *testing.T) {
assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis")
@ -286,7 +286,7 @@ func TestPreparePostForClient(t *testing.T) {
th.AddReactionToPost(post, th.BasicUser2, "angry")
post.HasReactions = true
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
t.Run("populates emojis", func(t *testing.T) {
assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3, emoji4}, clientPost.Metadata.Emojis, "should've populated post.Emojis")
@ -318,7 +318,7 @@ func TestPreparePostForClient(t *testing.T) {
post.AddProp(model.PostPropsOverrideIconURL, url)
post.AddProp(model.PostPropsOverrideIconEmoji, emoji)
return th.App.PreparePostForClient(th.Context, post, false, false, false)
return th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
}
emoji := "basketball"
@ -371,7 +371,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
t.Run("populates image dimensions", func(t *testing.T) {
imageDimensions := clientPost.Metadata.Images
@ -404,7 +404,7 @@ func TestPreparePostForClient(t *testing.T) {
post.AddProp(model.PostPropsOverrideIconEmoji, true)
require.NotPanics(t, func() {
_ = th.App.PreparePostForClient(th.Context, post, false, false, false)
_ = th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
})
})
@ -434,7 +434,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{})
// Reminder that only the first link gets an embed and dimensions
@ -469,7 +469,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
ogData := firstEmbed.Data.(*opengraph.OpenGraph)
@ -540,7 +540,7 @@ func TestPreparePostForClient(t *testing.T) {
post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
assert.Len(t, clientPost.Metadata.Embeds, 0)
assert.Len(t, clientPost.Metadata.Images, 0)
@ -556,7 +556,7 @@ func TestPreparePostForClient(t *testing.T) {
post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
assert.Greater(t, len(clientPost.Metadata.Embeds)+len(clientPost.Metadata.Images), 0)
})
@ -582,7 +582,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
post.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{})
t.Run("populates embeds", func(t *testing.T) {
assert.ElementsMatch(t, []*model.PostEmbed{
@ -627,7 +627,7 @@ func TestPreparePostForClient(t *testing.T) {
// DeleteAt isn't set on the post returned by App.DeletePost
post.DeleteAt = model.GetMillis()
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata“")
assert.Equal(t, "", clientPost.Message, "should've cleaned post content")
@ -662,7 +662,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
previewPost.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
require.Equal(t, referencedPost.Id, preview.PostID)
@ -721,7 +721,7 @@ func TestPreparePostForClient(t *testing.T) {
require.Nil(t, err)
previewPost.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
@ -759,7 +759,7 @@ func TestPreparePostForClient(t *testing.T) {
require.Nil(t, err)
previewPost.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
referencedPostFirstEmbed := preview.Post.Metadata.Embeds[0]
@ -806,7 +806,7 @@ func TestPreparePostForClient(t *testing.T) {
require.Nil(t, err)
previewPost.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
referencedPostMetadata := preview.Post.Metadata
@ -841,7 +841,7 @@ func TestPreparePostForClient(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
clientPost := th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
require.Equal(t, referencedPost.Id, preview.PostID)
@ -850,13 +850,13 @@ func TestPreparePostForClient(t *testing.T) {
*cfg.ServiceSettings.EnablePermalinkPreviews = false
})
th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnablePermalinkPreviews = true
})
clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, false, false, false)
clientPost2 := th.App.PreparePostForClient(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed2 := clientPost2.Metadata.Embeds[0]
preview2 := firstEmbed2.Data.(*model.PreviewPost)
require.Equal(t, referencedPost.Id, preview2.PostID)
@ -909,7 +909,7 @@ func testProxyLinkedImage(t *testing.T, th *TestHelper, shouldProxy bool) {
Message: fmt.Sprintf(postTemplate, imageURL),
}
clientPost := th.App.PreparePostForClient(th.Context, post, false, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{})
if shouldProxy {
assert.Equal(t, fmt.Sprintf(postTemplate, imageURL), post.Message, "should not have mutated original post")
@ -959,7 +959,7 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) {
require.Nil(t, err)
post.Metadata.Embeds = nil
embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, false, false, false).Metadata.Embeds
embeds := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, post, &model.PreparePostForClientOpts{}).Metadata.Embeds
require.Len(t, embeds, 1, "should have one embed")
embed := embeds[0]
@ -3098,7 +3098,7 @@ func TestSanitizePostMetaDataForAudit(t *testing.T) {
}, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
previewPost.Metadata.Embeds = nil
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, false, false, false)
clientPost := th.App.PreparePostForClientWithEmbedsAndImages(th.Context, previewPost, &model.PreparePostForClientOpts{})
firstEmbed := clientPost.Metadata.Embeds[0]
preview := firstEmbed.Data.(*model.PreviewPost)
require.Equal(t, referencedPost.Id, preview.PostID)

View file

@ -338,7 +338,7 @@ func (a *App) sendPersistentNotifications(post *model.Post, channel *model.Chann
}
if len(desktopUsers) != 0 {
post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, false, false, true)
post = a.PreparePostForClient(request.EmptyContext(a.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
postJSON, jsonErr := post.ToJSON()
if jsonErr != nil {
return errors.Wrapf(jsonErr, "failed to encode post to JSON")

View file

@ -5,6 +5,7 @@ package app
import (
"database/sql"
"errors"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
@ -36,3 +37,12 @@ func (a *App) GetPriorityForPostList(list *model.PostList) (map[string]*model.Po
func (a *App) IsPostPriorityEnabled() bool {
return *a.Config().ServiceSettings.PostPriority
}
func (a *App) DeletePriorityForPost(postId string) *model.AppError {
err := a.Srv().Store().PostPriority().Delete(postId)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return model.NewAppError("DeletePriorityForPost", "app.post_priority.delete_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}

View file

@ -11,6 +11,10 @@ func (ps *PropertyService) CreatePropertyValue(value *model.PropertyValue) (*mod
return ps.valueStore.Create(value)
}
func (ps *PropertyService) CreatePropertyValues(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
return ps.valueStore.CreateMany(values)
}
func (ps *PropertyService) GetPropertyValue(groupID, id string) (*model.PropertyValue, error) {
return ps.valueStore.Get(groupID, id)
}

View file

@ -427,7 +427,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
require.Nil(t, appErr)
assert.Equal(t, "https://example.com/icon.png", post.GetProp(model.PostPropsOverrideIconURL))
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
assert.Equal(t, "https://example.com/icon.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
})
@ -450,7 +450,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
require.Nil(t, appErr)
assert.Equal(t, "smile", post.GetProp(model.PostPropsOverrideIconEmoji))
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
})
@ -475,7 +475,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
require.Nil(t, appErr)
assert.Equal(t, emoji.Name, post.GetProp(model.PostPropsOverrideIconEmoji))
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
assert.Equal(t, fmt.Sprintf("/api/v4/emoji/%s/image", emoji.Id), clientPost.GetProp(model.PostPropsOverrideIconURL))
})
@ -498,7 +498,7 @@ func TestCreateWebhookPostWithOverriddenIcon(t *testing.T) {
require.Nil(t, appErr)
assert.Equal(t, ":smile:", post.GetProp(model.PostPropsOverrideIconEmoji))
clientPost := th.App.PreparePostForClient(th.Context, post, true, false, false)
clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{IsNewPost: true})
assert.Equal(t, "/static/emoji/1f604.png", clientPost.GetProp(model.PostPropsOverrideIconURL))
})

View file

@ -7683,6 +7683,27 @@ func (s *RetryLayerPostStore) Delete(rctx request.CTX, postID string, timestamp
}
func (s *RetryLayerPostStore) DeleteAllPostRemindersForPost(postId string) error {
tries := 0
for {
err := s.PostStore.DeleteAllPostRemindersForPost(postId)
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 *RetryLayerPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
tries := 0
@ -8625,6 +8646,27 @@ func (s *RetryLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostA
}
func (s *RetryLayerPostAcknowledgementStore) DeleteAllForPost(postID string) error {
tries := 0
for {
err := s.PostAcknowledgementStore.DeleteAllForPost(postID)
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 *RetryLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
tries := 0
@ -9570,6 +9612,27 @@ func (s *RetryLayerPropertyValueStore) Create(value *model.PropertyValue) (*mode
}
func (s *RetryLayerPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
tries := 0
for {
result, err := s.PropertyValueStore.CreateMany(values)
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 *RetryLayerPropertyValueStore) Delete(groupID string, id string) error {
tries := 0

View file

@ -109,6 +109,15 @@ func (s *SqlPostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowle
return nil
}
func (s *SqlPostAcknowledgementStore) DeleteAllForPost(postID string) error {
query := s.getQueryBuilder().
Delete("PostAcknowledgements").
Where(sq.Eq{"PostId": postID})
_, err := s.GetMaster().ExecBuilder(query)
return err
}
func (s *SqlPostAcknowledgementStore) GetForPost(postID string) ([]*model.PostAcknowledgement, error) {
var acknowledgements []*model.PostAcknowledgement

View file

@ -3245,6 +3245,14 @@ func (s *SqlPostStore) GetPostReminders(now int64) (_ []*model.PostReminder, err
return reminders, nil
}
func (s *SqlPostStore) DeleteAllPostRemindersForPost(postId string) error {
_, err := s.GetMaster().Exec(`DELETE from PostReminders WHERE PostId = ?`, postId)
if err != nil {
return errors.Wrapf(err, "failed to delete post reminders for postId %s", postId)
}
return nil
}
func (s *SqlPostStore) GetPostReminderMetadata(postID string) (*store.PostReminderMetadata, error) {
meta := &store.PostReminderMetadata{}
err := s.GetReplica().Get(meta, `SELECT c.id as ChannelID,

View file

@ -19,11 +19,13 @@ type SqlPropertyValueStore struct {
tableSelectQuery sq.SelectBuilder
}
var propertyValueColumns = []string{"ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt"}
func newPropertyValueStore(sqlStore *SqlStore) store.PropertyValueStore {
s := SqlPropertyValueStore{SqlStore: sqlStore}
s.tableSelectQuery = s.getQueryBuilder().
Select("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
Select(propertyValueColumns...).
From("PropertyValues")
return &s
@ -47,7 +49,7 @@ func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.Prope
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
Columns(propertyValueColumns...).
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
if _, err := s.GetMaster().ExecBuilder(builder); err != nil {
return nil, errors.Wrap(err, "property_value_create_insert")
@ -56,6 +58,46 @@ func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.Prope
return value, nil
}
func (s *SqlPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
if len(values) == 0 {
return nil, nil
}
transaction, err := s.GetMaster().Beginx()
if err != nil {
return nil, errors.Wrap(err, "property_value_create_many_begin_transaction")
}
defer finalizeTransactionX(transaction, &err)
for _, value := range values {
value.PreSave()
if err := value.IsValid(); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_isvalid")
}
valueJSON := value.Value
if s.IsBinaryParamEnabled() {
valueJSON = AppendBinaryFlag(valueJSON)
}
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns(propertyValueColumns...).
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
if _, err := transaction.ExecBuilder(builder); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_exec")
}
}
if err := transaction.Commit(); err != nil {
return nil, errors.Wrap(err, "property_value_create_many_commit_transaction")
}
return values, nil
}
func (s *SqlPropertyValueStore) Get(groupID, id string) (*model.PropertyValue, error) {
builder := s.tableSelectQuery.Where(sq.Eq{"id": id})
@ -242,7 +284,7 @@ func (s *SqlPropertyValueStore) Upsert(values []*model.PropertyValue) (_ []*mode
builder := s.getQueryBuilder().
Insert("PropertyValues").
Columns("ID", "TargetID", "TargetType", "GroupID", "FieldID", "Value", "CreateAt", "UpdateAt", "DeleteAt").
Columns(propertyValueColumns...).
Values(value.ID, value.TargetID, value.TargetType, value.GroupID, value.FieldID, valueJSON, value.CreateAt, value.UpdateAt, value.DeleteAt)
builder = builder.SuffixExpr(sq.Expr(

View file

@ -414,6 +414,7 @@ type PostStore interface {
GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, error)
SetPostReminder(reminder *model.PostReminder) error
GetPostReminders(now int64) ([]*model.PostReminder, error)
DeleteAllPostRemindersForPost(postId string) error
GetPostReminderMetadata(postID string) (*PostReminderMetadata, error)
// GetNthRecentPostTime returns the CreateAt time of the nth most recent post.
GetNthRecentPostTime(n int64) (int64, error)
@ -1064,6 +1065,7 @@ type PostAcknowledgementStore interface {
SaveWithModel(acknowledgement *model.PostAcknowledgement) (*model.PostAcknowledgement, error)
BatchSave(acknowledgements []*model.PostAcknowledgement) ([]*model.PostAcknowledgement, error)
Delete(acknowledgement *model.PostAcknowledgement) error
DeleteAllForPost(postID string) error
BatchDelete(acknowledgements []*model.PostAcknowledgement) error
}
@ -1117,6 +1119,7 @@ type PropertyFieldStore interface {
type PropertyValueStore interface {
Create(value *model.PropertyValue) (*model.PropertyValue, error)
CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error)
Get(groupID, id string) (*model.PropertyValue, error)
GetMany(groupID string, ids []string) ([]*model.PropertyValue, error)
SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error)

View file

@ -135,7 +135,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
pva1, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: u1.Id,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: fieldA.ID,
Value: vala1,
@ -144,7 +144,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
pvb1, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: u1.Id,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: fieldB.ID,
Value: valab1,
@ -153,7 +153,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
pva2, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: u2.Id,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: fieldA.ID,
Value: vala2,
@ -162,7 +162,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
pva3, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: u3.Id,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: fieldA.ID,
Value: vala1,
@ -171,7 +171,7 @@ func createTestUsers(t *testing.T, rctx request.CTX, ss store.Store) ([]*model.U
pva4, err := ss.PropertyValue().Create(&model.PropertyValue{
TargetID: u3.Id,
TargetType: "user",
TargetType: model.PropertyValueTargetTypeUser,
GroupID: groupID,
FieldID: fieldC.ID,
Value: valc2,

View file

@ -80,6 +80,24 @@ func (_m *PostAcknowledgementStore) Delete(acknowledgement *model.PostAcknowledg
return r0
}
// DeleteAllForPost provides a mock function with given fields: postID
func (_m *PostAcknowledgementStore) DeleteAllForPost(postID string) error {
ret := _m.Called(postID)
if len(ret) == 0 {
panic("no return value specified for DeleteAllForPost")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(postID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: postID, userID
func (_m *PostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
ret := _m.Called(postID, userID)

View file

@ -156,6 +156,24 @@ func (_m *PostStore) Delete(rctx request.CTX, postID string, timestamp int64, de
return r0
}
// DeleteAllPostRemindersForPost provides a mock function with given fields: postId
func (_m *PostStore) DeleteAllPostRemindersForPost(postId string) error {
ret := _m.Called(postId)
if len(ret) == 0 {
panic("no return value specified for DeleteAllPostRemindersForPost")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(postId)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: rctx, id, opts, userID, sanitizeOptions
func (_m *PostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
ret := _m.Called(rctx, id, opts, userID, sanitizeOptions)

View file

@ -44,6 +44,36 @@ func (_m *PropertyValueStore) Create(value *model.PropertyValue) (*model.Propert
return r0, r1
}
// CreateMany provides a mock function with given fields: values
func (_m *PropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
ret := _m.Called(values)
if len(ret) == 0 {
panic("no return value specified for CreateMany")
}
var r0 []*model.PropertyValue
var r1 error
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) ([]*model.PropertyValue, error)); ok {
return rf(values)
}
if rf, ok := ret.Get(0).(func([]*model.PropertyValue) []*model.PropertyValue); ok {
r0 = rf(values)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.PropertyValue)
}
}
if rf, ok := ret.Get(1).(func([]*model.PropertyValue) error); ok {
r1 = rf(values)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Delete provides a mock function with given fields: groupID, id
func (_m *PropertyValueStore) Delete(groupID string, id string) error {
ret := _m.Called(groupID, id)

View file

@ -18,6 +18,7 @@ import (
func TestPropertyValueStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("CreatePropertyValue", func(t *testing.T) { testCreatePropertyValue(t, rctx, ss) })
t.Run("CreateManyPropertyValues", func(t *testing.T) { testCreateManyPropertyValues(t, rctx, ss) })
t.Run("CreatePropertyValueWithArray", func(t *testing.T) { testCreatePropertyValueWithArray(t, rctx, ss) })
t.Run("GetPropertyValue", func(t *testing.T) { testGetPropertyValue(t, rctx, ss) })
t.Run("GetManyPropertyValues", func(t *testing.T) { testGetManyPropertyValues(t, rctx, ss) })
@ -76,6 +77,225 @@ func testCreatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
})
}
func testCreateManyPropertyValues(t *testing.T, _ request.CTX, ss store.Store) {
t.Run("should return nil when given empty slice", func(t *testing.T) {
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{})
require.NoError(t, err)
require.Nil(t, values)
})
t.Run("should fail if any property value is not valid", func(t *testing.T) {
validValue := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"valid value"`),
}
invalidValue := &model.PropertyValue{
TargetID: "", // Invalid: empty target ID
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"invalid value"`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{validValue, invalidValue})
require.Zero(t, values)
require.ErrorContains(t, err, "model.property_value.is_valid.app_error")
// Verify no values were created
results, err := ss.PropertyValue().SearchPropertyValues(model.PropertyValueSearchOpts{
TargetIDs: []string{validValue.TargetID},
PerPage: 10,
})
require.NoError(t, err)
require.Empty(t, results)
})
t.Run("should be able to create multiple property values", func(t *testing.T) {
value1 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"value 1"`),
}
value2 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"value 2"`),
}
value3 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"value 3"`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2, value3})
require.NoError(t, err)
require.Len(t, values, 3)
// Verify all values have IDs and timestamps set
for i, value := range values {
require.NotZero(t, value.ID)
require.NotZero(t, value.CreateAt)
require.NotZero(t, value.UpdateAt)
require.Zero(t, value.DeleteAt)
require.Equal(t, json.RawMessage(fmt.Sprintf(`"value %d"`, i+1)), value.Value)
}
// Verify values can be retrieved from database
retrievedValues, err := ss.PropertyValue().GetMany("", []string{value1.ID, value2.ID, value3.ID})
require.NoError(t, err)
require.Len(t, retrievedValues, 3)
})
t.Run("should handle array values", func(t *testing.T) {
value1 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`["option1", "option2"]`),
}
value2 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`["option3", "option4", "option5"]`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, values, 2)
// Verify array values are preserved
var arrayValues1 []string
require.NoError(t, json.Unmarshal(values[0].Value, &arrayValues1))
require.Equal(t, []string{"option1", "option2"}, arrayValues1)
var arrayValues2 []string
require.NoError(t, json.Unmarshal(values[1].Value, &arrayValues2))
require.Equal(t, []string{"option3", "option4", "option5"}, arrayValues2)
})
t.Run("should enforce uniqueness constraints", func(t *testing.T) {
groupID := model.NewId()
targetID := model.NewId()
fieldID := model.NewId()
// Create first value
value1 := &model.PropertyValue{
TargetID: targetID,
TargetType: "test_type",
GroupID: groupID,
FieldID: fieldID,
Value: json.RawMessage(`"first value"`),
}
_, err := ss.PropertyValue().Create(value1)
require.NoError(t, err)
// Try to create duplicate value
value2 := &model.PropertyValue{
TargetID: targetID,
TargetType: "test_type",
GroupID: groupID,
FieldID: fieldID,
Value: json.RawMessage(`"duplicate value"`),
}
value3 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"unique value"`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value2, value3})
require.Error(t, err)
require.Zero(t, values)
// Verify the unique value was not created due to transaction rollback
results, err := ss.PropertyValue().SearchPropertyValues(model.PropertyValueSearchOpts{
TargetIDs: []string{value3.TargetID},
PerPage: 10,
})
require.NoError(t, err)
require.Empty(t, results)
})
t.Run("should create values with same field but different targets", func(t *testing.T) {
groupID := model.NewId()
fieldID := model.NewId()
value1 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: groupID,
FieldID: fieldID,
Value: json.RawMessage(`"target 1 value"`),
}
value2 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: groupID,
FieldID: fieldID,
Value: json.RawMessage(`"target 2 value"`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, values, 2)
// Verify both values were created successfully
for _, value := range values {
require.NotZero(t, value.ID)
require.Equal(t, groupID, value.GroupID)
require.Equal(t, fieldID, value.FieldID)
}
})
t.Run("should create values across different groups", func(t *testing.T) {
value1 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"group 1 value"`),
}
value2 := &model.PropertyValue{
TargetID: model.NewId(),
TargetType: "test_type",
GroupID: model.NewId(),
FieldID: model.NewId(),
Value: json.RawMessage(`"group 2 value"`),
}
values, err := ss.PropertyValue().CreateMany([]*model.PropertyValue{value1, value2})
require.NoError(t, err)
require.Len(t, values, 2)
// Verify values are in different groups
require.NotEqual(t, values[0].GroupID, values[1].GroupID)
require.Contains(t, string(values[0].Value), "group 1")
require.Contains(t, string(values[1].Value), "group 2")
})
}
func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store) {
t.Run("should fail on nonexisting value", func(t *testing.T) {
value, err := ss.PropertyValue().Get("", model.NewId())

View file

@ -6118,6 +6118,22 @@ func (s *TimerLayerPostStore) Delete(rctx request.CTX, postID string, timestamp
return err
}
func (s *TimerLayerPostStore) DeleteAllPostRemindersForPost(postId string) error {
start := time.Now()
err := s.PostStore.DeleteAllPostRemindersForPost(postId)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("PostStore.DeleteAllPostRemindersForPost", success, elapsed)
}
return err
}
func (s *TimerLayerPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptions, userID string, sanitizeOptions map[string]bool) (*model.PostList, error) {
start := time.Now()
@ -6869,6 +6885,22 @@ func (s *TimerLayerPostAcknowledgementStore) Delete(acknowledgement *model.PostA
return err
}
func (s *TimerLayerPostAcknowledgementStore) DeleteAllForPost(postID string) error {
start := time.Now()
err := s.PostAcknowledgementStore.DeleteAllForPost(postID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("PostAcknowledgementStore.DeleteAllForPost", success, elapsed)
}
return err
}
func (s *TimerLayerPostAcknowledgementStore) Get(postID string, userID string) (*model.PostAcknowledgement, error) {
start := time.Now()
@ -7589,6 +7621,22 @@ func (s *TimerLayerPropertyValueStore) Create(value *model.PropertyValue) (*mode
return result, err
}
func (s *TimerLayerPropertyValueStore) CreateMany(values []*model.PropertyValue) ([]*model.PropertyValue, error) {
start := time.Now()
result, err := s.PropertyValueStore.CreateMany(values)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("PropertyValueStore.CreateMany", success, elapsed)
}
return result, err
}
func (s *TimerLayerPropertyValueStore) Delete(groupID string, id string) error {
start := time.Now()

View file

@ -1741,6 +1741,14 @@
"id": "api.config.update_config.translations.app_error",
"translation": "Failed to update server translations."
},
{
"id": "api.content_flagging.error.comment_required",
"translation": "Please add a comment explaining why youre flagging this message."
},
{
"id": "api.content_flagging.error.comment_too_long",
"translation": "Message flagging reason cannot be longer than {{.MaxLength}} characters."
},
{
"id": "api.content_flagging.error.disabled",
"translation": "Content flagging feature is disabled."
@ -1749,6 +1757,26 @@
"id": "api.content_flagging.error.license",
"translation": "Your license does not support content flagging."
},
{
"id": "api.content_flagging.error.not_available_on_team",
"translation": "Content flagging feature is not enabled on this team."
},
{
"id": "api.content_flagging.error.post_not_in_progress",
"translation": "The flagged post must be in pending or assigned status to be kept or removed."
},
{
"id": "api.content_flagging.error.reason_invalid",
"translation": "Unknown reason specified for flagging message."
},
{
"id": "api.content_flagging.error.reason_required",
"translation": "Please select a reason for flagging this message."
},
{
"id": "api.content_flagging.error.reviewer_only",
"translation": "You do not have permission to view this resource."
},
{
"id": "api.context.404.app_error",
"translation": "Sorry, we could not find the page."
@ -5138,6 +5166,102 @@
"id": "app.compliance.save.saving.app_error",
"translation": "We encountered an error saving the compliance report."
},
{
"id": "app.content_flagging.can_flag_post.in_progress",
"translation": "Cannot flag this post as is already flagged."
},
{
"id": "app.content_flagging.can_flag_post.removed",
"translation": "Cannot flag this post it was removed in a previous flagging request."
},
{
"id": "app.content_flagging.can_flag_post.retained",
"translation": "Cannot flag this post as it was retained in a previous flagging request."
},
{
"id": "app.content_flagging.can_flag_post.unknown",
"translation": "Cannot flag this post as it is in unknown status."
},
{
"id": "app.content_flagging.create_property_values.app_error",
"translation": "Unable to save property values for the flagged post."
},
{
"id": "app.content_flagging.delete_post.app_error",
"translation": "Unable to soft-delete the flagged post."
},
{
"id": "app.content_flagging.flag_post.marshal_comment.app_error",
"translation": "Failed to marshal flagging user's comment"
},
{
"id": "app.content_flagging.flag_post.marshal_reason.app_error",
"translation": "Failed to marshal flagging user's reason"
},
{
"id": "app.content_flagging.flag_post_confirmation.message",
"translation": "The message from @{{.username}} has been flagged for review. You will be notified once it is reviewed by a Content Reviewer. "
},
{
"id": "app.content_flagging.get_group.error",
"translation": "Failed to get Content Flagging bot."
},
{
"id": "app.content_flagging.get_status_property.app_error",
"translation": "Failed to get Status property field."
},
{
"id": "app.content_flagging.get_users_in_team.app_error",
"translation": "Failed to search reviewers in team."
},
{
"id": "app.content_flagging.keep_flag_post.marshal_comment.app_error",
"translation": "Failed to marshal reviewer comment"
},
{
"id": "app.content_flagging.keep_post.status_update.app_error",
"translation": "Failed to update flagged post status when undeleting flagged post "
},
{
"id": "app.content_flagging.keep_post.undelete.app_error",
"translation": "Failed to update post in database when attempting to undelete the flagged post."
},
{
"id": "app.content_flagging.marshal_property_values.app_error",
"translation": "Failed to marshal Content Flagging property values to send in WebSocket event."
},
{
"id": "app.content_flagging.no_status_property.app_error",
"translation": "Cannot fetch flagged post as the post is not flagged."
},
{
"id": "app.content_flagging.permanently_delete.app_error",
"translation": "Failed to overwrite post with scrubbed post when permanently deleting flagged post."
},
{
"id": "app.content_flagging.permanently_delete.marshal_comment.app_error",
"translation": "Failed to marshal reviewer comment"
},
{
"id": "app.content_flagging.permanently_delete.update_property_value.app_error",
"translation": "Failed to update flagged post status when permanently deleting flagged post."
},
{
"id": "app.content_flagging.restore_file_info.app_error",
"translation": "Failed to restore file info for the flagged post."
},
{
"id": "app.content_flagging.search_property_fields.app_error",
"translation": "Failed to search Content Flagging property fields."
},
{
"id": "app.content_flagging.search_property_values.app_error",
"translation": "Failed to fetch post's content flagging property values from the database."
},
{
"id": "app.content_flagging.search_status_property.app_error",
"translation": "Failed to search Property Values for the flagged post."
},
{
"id": "app.create_basic_user.save_member.app_error",
"translation": "Unable to create default team memberships"
@ -6846,6 +6970,10 @@
"id": "app.post_persistent_notification.delete_by_team.app_error",
"translation": "Unable to delete the persistent notifications by team."
},
{
"id": "app.post_priority.delete_for_post.app_error",
"translation": "Failed to permanently delete post priority data from database for post."
},
{
"id": "app.post_priority.delete_persistent_notification_post.app_error",
"translation": "Failed to delete persistent notification post"
@ -7212,6 +7340,10 @@
"id": "app.system.complete_onboarding_request.no_first_user",
"translation": "Onboarding can only be completed by a System Administrator."
},
{
"id": "app.system.content_review_bot.bot_displayname",
"translation": "Content Review"
},
{
"id": "app.system.get_by_name.app_error",
"translation": "Unable to find the system variable."

View file

@ -560,17 +560,17 @@ func (_m *MockAppIface) PermanentDeleteChannel(rctx request.CTX, channel *model.
return r0
}
// PreparePostForClient provides a mock function with given fields: rctx, post, isNewPost, includeDeleted, includePriority
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, isNewPost bool, includeDeleted bool, includePriority bool) *model.Post {
ret := _m.Called(rctx, post, isNewPost, includeDeleted, includePriority)
// PreparePostForClient provides a mock function with given fields: rctx, post, opts
func (_m *MockAppIface) PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
ret := _m.Called(rctx, post, opts)
if len(ret) == 0 {
panic("no return value specified for PreparePostForClient")
}
var r0 *model.Post
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, bool, bool, bool) *model.Post); ok {
r0 = rf(rctx, post, isNewPost, includeDeleted, includePriority)
if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.PreparePostForClientOpts) *model.Post); ok {
r0 = rf(rctx, post, opts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)

View file

@ -83,7 +83,7 @@ type AppIface interface {
DeleteAcknowledgementForPostWithModel(rctx request.CTX, acknowledgement *model.PostAcknowledgement) *model.AppError
SaveAcknowledgementsForPost(rctx request.CTX, postID string, userIDs []string) ([]*model.PostAcknowledgement, *model.AppError)
GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError)
PreparePostForClient(rctx request.CTX, post *model.Post, isNewPost, includeDeleted, includePriority bool) *model.Post
PreparePostForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post
}
// errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency.

View file

@ -470,7 +470,7 @@ func (scs *Service) handlePostError(postId string, task syncTask, rc *model.Remo
}
// Populate metadata for the retry post
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, false, false, true)
post = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
syncMsg := model.NewSyncMsg(task.channelID)
syncMsg.Posts = []*model.Post{post}

View file

@ -332,7 +332,7 @@ func (scs *Service) fetchPostsForSync(sd *syncData) error {
// Populate metadata for all posts before syncing
for i, post := range sd.posts {
if post != nil {
sd.posts[i] = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, false, false, true)
sd.posts[i] = scs.app.PreparePostForClient(request.EmptyContext(scs.server.Log()), post, &model.PreparePostForClientOpts{IncludePriority: true})
}
}

View file

@ -427,3 +427,11 @@ const (
AuditEventUpdateIncomingHook = "updateIncomingHook" // update incoming webhook
AuditEventUpdateOutgoingHook = "updateOutgoingHook" // update outgoing webhook
)
// Content Flagging
const (
AuditEventFlagPost = "flagPost" // flag post for review
AuditEventGetFlaggedPost = "getFlaggedPost" // get flagged post details
AuditEventPermanentlyRemoveFlaggedPost = "permanentlyRemoveFlaggedPost" // permanently remove flagged post
AuditEventKeepFlaggedPost = "keepFlaggedPost" // keep flagged post
)

View file

@ -734,6 +734,21 @@ func (c *Client4) GetFlaggingConfiguration(ctx context.Context) (*ContentFlaggin
return &config, BuildResponse(r), nil
}
func (c *Client4) FlagPostForContentReview(ctx context.Context, postId string, flagRequest *FlagContentRequest) (*Response, error) {
buf, err := json.Marshal(flagRequest)
if err != nil {
return nil, NewAppError("FlagPostForContentReview", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPost(ctx, fmt.Sprintf("%s/post/%s/flag", c.contentFlaggingRoute(), postId), string(buf))
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId string) (map[string]bool, *Response, error) {
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/team/"+teamId+"/status", "")
if err != nil {
@ -747,6 +762,32 @@ func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId s
return status, BuildResponse(r), nil
}
func (c *Client4) GetPostPropertyValues(ctx context.Context, postId string) (*[]PropertyValue, *Response, error) {
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/post/"+postId+"/field_values", "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var propertyValues []PropertyValue
if err := json.NewDecoder(r.Body).Decode(&propertyValues); err != nil {
return nil, nil, NewAppError("GetFlaggingConfiguration", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &propertyValues, BuildResponse(r), nil
}
func (c *Client4) GetContentFlaggedPost(ctx context.Context, postId string) (*Post, *Response, error) {
r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/post/"+postId, "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var post Post
if err := json.NewDecoder(r.Body).Decode(&post); err != nil {
return nil, nil, NewAppError("GetContentFlaggedPost", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &post, BuildResponse(r), nil
}
func (c *Client4) bookmarksRoute(channelId string) string {
return c.channelRoute(channelId) + "/bookmarks"
}

View file

@ -3,4 +3,64 @@
package model
const ContentFlaggingGroupName = "content_flagging"
import (
"net/http"
"slices"
"unicode/utf8"
)
const (
ContentFlaggingGroupName = "content_flagging"
ContentFlaggingPostType = PostCustomTypePrefix + "spillage_report"
ContentFlaggingBotUsername = "content-review"
commentMaxRunes = 1000
)
const (
ContentFlaggingStatusPending = "Pending"
ContentFlaggingStatusAssigned = "Assigned"
ContentFlaggingStatusRemoved = "Removed"
ContentFlaggingStatusRetained = "Retained"
)
type FlagContentRequest struct {
Reason string `json:"reason"`
Comment string `json:"comment,omitempty"`
}
func (f *FlagContentRequest) IsValid(commentRequired bool, validReasons []string) *AppError {
if f.Reason == "" {
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.reason_required", nil, "", http.StatusBadRequest)
}
if !slices.Contains(validReasons, f.Reason) {
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.reason_invalid", nil, "", http.StatusBadRequest)
}
if commentRequired && f.Comment == "" {
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.comment_required", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
return NewAppError("FlagContentRequest.IsValid", "api.content_flagging.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
}
return nil
}
type FlagContentActionRequest struct {
Comment string `json:"comment,omitempty"`
}
func (f *FlagContentActionRequest) IsValid(commentRequired bool) *AppError {
if commentRequired && f.Comment == "" {
return NewAppError("FlagContentActionRequest.IsValid", "api.content_flagging.error.comment_required", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(f.Comment) > commentMaxRunes {
return NewAppError("FlagContentActionRequest.IsValid", "api.content_flagging.error.comment_too_long", map[string]any{"MaxLength": commentMaxRunes}, "", http.StatusBadRequest)
}
return nil
}

View file

@ -227,6 +227,9 @@ func (cfs *ContentFlaggingSettings) IsValid() *AppError {
}
type ContentFlaggingReportingConfig struct {
Reasons *[]string `json:"reasons"`
ReporterCommentRequired *bool `json:"reporter_comment_required"`
Reasons *[]string `json:"reasons"`
ReporterCommentRequired *bool `json:"reporter_comment_required"`
ReviewerCommentRequired *bool `json:"reviewer_comment_required"`
NotifyReporterOnDismissal *bool `json:"notify_reporter_on_dismissal,omitempty"`
NotifyReporterOnRemoval *bool `json:"notify_reporter_on_removal,omitempty"`
}

View file

@ -0,0 +1,118 @@
package model
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFlagContentRequest_IsValid(t *testing.T) {
validReasons := []string{"spam", "harassment", "inappropriate"}
t.Run("valid request without comment", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "",
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("valid request with comment", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "harassment",
Comment: "This is inappropriate content",
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("valid request with comment when required", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "inappropriate",
Comment: "This violates community guidelines",
}
err := req.IsValid(true, validReasons)
assert.Nil(t, err)
})
t.Run("missing comment when required", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "",
}
err := req.IsValid(true, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.comment_required", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("missing reason", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "",
Comment: "Some comment",
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.reason_required", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("invalid reason", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "invalid_reason",
Comment: "Some comment",
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.reason_invalid", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
})
t.Run("comment too long", func(t *testing.T) {
longComment := strings.Repeat("a", commentMaxRunes+1)
req := &FlagContentRequest{
Reason: "spam",
Comment: longComment,
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.comment_too_long", err.Id)
assert.Equal(t, http.StatusBadRequest, err.StatusCode)
assert.Equal(t, commentMaxRunes, err.params["MaxLength"])
})
t.Run("comment at max length", func(t *testing.T) {
maxLengthComment := strings.Repeat("a", commentMaxRunes)
req := &FlagContentRequest{
Reason: "harassment",
Comment: maxLengthComment,
}
err := req.IsValid(false, validReasons)
assert.Nil(t, err)
})
t.Run("unicode comment length validation", func(t *testing.T) {
// Test with unicode characters that take multiple bytes
unicodeComment := strings.Repeat("🚀", commentMaxRunes+1)
req := &FlagContentRequest{
Reason: "spam",
Comment: unicodeComment,
}
err := req.IsValid(false, validReasons)
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.comment_too_long", err.Id)
})
t.Run("empty valid reasons list", func(t *testing.T) {
req := &FlagContentRequest{
Reason: "spam",
Comment: "Some comment",
}
err := req.IsValid(false, []string{})
assert.NotNil(t, err)
assert.Equal(t, "api.content_flagging.error.reason_invalid", err.Id)
})
}

View file

@ -1115,3 +1115,11 @@ func DefaultUpdatePostOptions() *UpdatePostOptions {
IsRestorePost: false,
}
}
type PreparePostForClientOpts struct {
IsNewPost bool
IsEditPost bool
IncludePriority bool
RetainContent bool
IncludeDeleted bool
}

View file

@ -14,6 +14,9 @@ import (
const (
PropertyValueTargetIDMaxRunes = 255
PropertyValueTargetTypeMaxRunes = 255
PropertyValueTargetTypePost = "post"
PropertyValueTargetTypeUser = "user"
)
type PropertyValue struct {

View file

@ -98,6 +98,7 @@ const (
WebsocketEventCPAFieldUpdated WebsocketEventType = "custom_profile_attributes_field_updated"
WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted"
WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated"
WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated"
WebSocketMsgTypeResponse = "response"
WebSocketMsgTypeEvent = "event"

View file

@ -22,6 +22,7 @@ import {
HostedCustomerTypes,
ChannelBookmarkTypes,
ScheduledPostTypes,
ContentFlaggingTypes,
} from 'mattermost-redux/action_types';
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
@ -646,6 +647,9 @@ export function handleEvent(msg) {
case SocketEvents.CPA_FIELD_DELETED:
dispatch(handleCustomAttributesDeleted(msg));
break;
case SocketEvents.CONTENT_FLAGGING_REPORT_VALUE_CHANGED:
dispatch(handleContentFlaggingReportValueChanged(msg));
break;
default:
}
@ -1976,3 +1980,10 @@ export function handleCustomAttributesDeleted(msg) {
data: msg.data.field_id,
};
}
export function handleContentFlaggingReportValueChanged(msg) {
return {
type: ContentFlaggingTypes.CONTENT_FLAGGING_REPORT_VALUE_UPDATED,
data: msg.data,
};
}

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
import {
getContentFlaggingConfig,
getPostContentFlaggingValues,
loadPostContentFlaggingFields,
} from 'mattermost-redux/actions/content_flagging';
import {
contentFlaggingConfig,
contentFlaggingFields,
postContentFlaggingValues,
} from 'mattermost-redux/selectors/entities/content_flagging';
import {makeUseEntity} from 'components/common/hooks/useEntity';
export const useContentFlaggingFields = makeUseEntity<NameMappedPropertyFields | undefined>({
name: 'useContentFlaggingFields',
fetch: loadPostContentFlaggingFields,
selector: contentFlaggingFields,
});
export const usePostContentFlaggingValues = makeUseEntity<Array<PropertyValue<unknown>>>({
name: 'usePostContentFlaggingValues',
fetch: getPostContentFlaggingValues,
selector: postContentFlaggingValues,
});
export const useContentFlaggingConfig = makeUseEntity<ContentFlaggingConfig>({
name: 'useContentFlaggingConfig',
fetch: getContentFlaggingConfig,
selector: contentFlaggingConfig,
});

View file

@ -31,6 +31,7 @@ export type OwnProps = {
isEditHistory?: boolean;
disableDownload?: boolean;
disableActions?: boolean;
usePostAsSource?: boolean;
}
function makeMapStateToProps() {
@ -42,7 +43,13 @@ function makeMapStateToProps() {
var fileInfos: FileInfo[];
if (ownProps.isEditHistory) {
if (ownProps.usePostAsSource) {
if (ownProps.post.metadata && ownProps.post.metadata.files) {
fileInfos = ownProps.post.metadata.files;
} else {
fileInfos = [];
}
} else if (ownProps.isEditHistory) {
fileInfos = getFilesForEditHistory(state, ownProps.post);
} else {
fileInfos = selectFilesForPost(state, postId);

View file

@ -52,4 +52,12 @@
height: 110px !important;
}
}
.FlagPostModal__request-error {
display: flex;
width: 90%;
align-items: center;
color: var(--error-text);
font-size: 12px;
}
}

View file

@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import {screen, waitFor, act} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
@ -14,6 +16,9 @@ import type {GlobalState} from 'types/store';
import FlagPostModal from './flag_post_modal';
jest.mock('mattermost-redux/client');
const mockedClient4 = jest.mocked(Client4);
describe('components/FlagPostModal', () => {
const baseState: DeepPartial<GlobalState> = {
entities: {
@ -86,4 +91,45 @@ describe('components/FlagPostModal', () => {
expect(screen.getByTestId('FlagPostModal__comment_section_title')).toHaveTextContent('Comment (optional)');
});
it('should call Client4.flagPost when submit button is clicked with valid form data', async () => {
const mockFlagPost = jest.fn().mockResolvedValue({});
mockedClient4.flagPost = mockFlagPost;
const onExited = jest.fn();
renderWithContext(
<FlagPostModal
postId={'post_id'}
onExited={onExited}
/>,
baseState,
);
// Select a reason
await userEvent.click(screen.getByText('Select a reason for flagging'));
await userEvent.click(screen.getByText('Reason 1'));
// Add a comment
const commentTextbox = screen.getByPlaceholderText('Describe your concern...');
await act(async () => {
await userEvent.type(commentTextbox, 'This is inappropriate content');
});
// Click submit
const submitButton = screen.getByText('Submit');
await act(async () => {
await userEvent.click(submitButton);
});
// Verify API call was made
await waitFor(() => {
expect(mockFlagPost).toHaveBeenCalledWith('post_id', 'Reason 1', 'This is inappropriate content');
});
// Verify modal was closed
await waitFor(() => {
expect(onExited).toHaveBeenCalled();
});
});
});

View file

@ -7,9 +7,11 @@ import {useDispatch, useSelector} from 'react-redux';
import ReactSelect, {type StylesConfig} from 'react-select';
import {GenericModal} from '@mattermost/components';
import type {ServerError} from '@mattermost/types/errors';
import type {PostPreviewMetadata} from '@mattermost/types/posts';
import {getContentFlaggingConfig} from 'mattermost-redux/actions/content_flagging';
import {Client4} from 'mattermost-redux/client';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {contentFlaggingConfig} from 'mattermost-redux/selectors/entities/content_flagging';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
@ -47,6 +49,8 @@ export default function FlagPostModal({postId, onExited}: Props) {
const [reason, setReason] = React.useState<string>('');
const [commentError, setCommentError] = React.useState<string>('');
const [reasonError, setReasonError] = React.useState<string>('');
const [requestError, setRequestError] = React.useState<string>('');
const [submitting, setSubmitting] = React.useState<boolean>(false);
const [showCommentPreview, setShowCommentPreview] = React.useState<boolean>(false);
const label = formatMessage({id: 'flag_message_modal.heading', defaultMessage: 'Flag message'});
@ -67,7 +71,7 @@ export default function FlagPostModal({postId, onExited}: Props) {
return [];
}
return contentFlaggingSettings.reasons.map((reason) => ({
return contentFlaggingSettings.reasons.map((reason: string) => ({
value: reason,
label: reason,
}));
@ -138,16 +142,24 @@ export default function FlagPostModal({postId, onExited}: Props) {
return hasError;
}, [comment, contentFlaggingSettings?.reporter_comment_required, formatMessage, reason]);
const handleConfirm = useCallback(() => {
const handleConfirm = useCallback(async () => {
const hasError = validateForm();
if (hasError) {
return;
}
// TODO: Implement the flagging action here in a follow up PR
onExited();
}, [validateForm, onExited]);
try {
setSubmitting(true);
await Client4.flagPost(post.id, reason, comment);
onExited();
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
setRequestError((error as ServerError).message);
} finally {
setSubmitting(false);
}
}, [validateForm, post.id, reason, comment, onExited]);
return (
<GenericModal
@ -163,6 +175,7 @@ export default function FlagPostModal({postId, onExited}: Props) {
confirmButtonText={submitButtonText}
onExited={onExited}
autoCloseOnConfirmButton={false}
isConfirmDisabled={submitting}
>
<div className='FlagPostModal FlagPostModal__body'>
<div className='FlagPostModal__section FlagPostModal__post_preview'>
@ -233,6 +246,12 @@ export default function FlagPostModal({postId, onExited}: Props) {
maxLength={1000}
/>
</div>
{requestError &&
<div className='FlagPostModal__request-error'>
<i className='icon icon-alert-outline'/>
<span>{requestError}</span>
</div>
}
</div>
</GenericModal>
);

View file

@ -1,11 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react';
import type {ComponentProps} from 'react';
import React from 'react';
import type {Post, PostType} from '@mattermost/types/posts';
import {Client4} from 'mattermost-redux/client';
import {Posts} from 'mattermost-redux/constants';
import {renderWithContext, screen} from 'tests/react_testing_utils';
@ -17,6 +19,13 @@ import PostMarkdown from './post_markdown';
jest.mock('components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer', () => {
return jest.fn(() => <div data-testid='post-preview-property-renderer-mock'>{'PostPreviewPropertyRenderer Mock'}</div>);
});
jest.mock('mattermost-redux/client');
jest.mock('components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal', () => {
return jest.fn(() => <div data-testid='keep-remove-flagged-message-confirmation-modal'>{'KeepRemoveFlaggedMessageConfirmationModal Mock'}</div>);
});
const mockedClient4 = jest.mocked(Client4);
describe('components/PostMarkdown', () => {
const baseProps: ComponentProps<typeof PostMarkdown> = {
@ -289,11 +298,18 @@ describe('components/PostMarkdown', () => {
expect(screen.queryByText('world!', {exact: true})).toBeInTheDocument();
});
test('should render data spillage card', () => {
test('should render data spillage card', async () => {
const reportedPost = TestHelper.getPostMock({
id: 'reported_post_id',
message: 'This is the reported post',
user_id: 'user_id_1',
channel_id: 'channel_id_1',
});
const dataSpillageReportPost = TestHelper.getPostMock({
type: PostTypes.CUSTOM_DATA_SPILLAGE_REPORT as PostType,
props: {
reported_post_id: 'reported_post_id',
reported_post_id: reportedPost.id,
},
});
@ -302,7 +318,10 @@ describe('components/PostMarkdown', () => {
message: 'See ~test',
post: dataSpillageReportPost,
};
mockedClient4.getFlaggedPost = jest.fn().mockResolvedValue(reportedPost);
renderWithContext(<PostMarkdown {...props}/>, state);
await act(async () => {});
expect(screen.queryByTestId('data-spillage-report')).toBeInTheDocument();
});

View file

@ -9,7 +9,7 @@ import type {Post} from '@mattermost/types/posts';
import {Posts} from 'mattermost-redux/constants';
import Markdown from 'components/markdown';
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
import {DataSpillageReport} from 'components/post_view/data_spillage_report/data_spillage_report';
import {PostTypes} from 'utils/constants';
import {isChannelNamesMap, type TextFormattingOptions} from 'utils/text_formatting';

View file

@ -5,12 +5,21 @@ import {screen} from '@testing-library/react';
import React from 'react';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import DataSpillageAction from './data_spillage_actions';
describe('DataSpillageAction', () => {
test('should render both action buttons', () => {
renderWithContext(<DataSpillageAction/>);
const flaggedPost = TestHelper.getPostMock();
const reportingUser = TestHelper.getUserMock();
renderWithContext(
<DataSpillageAction
flaggedPost={flaggedPost}
reportingUser={reportingUser}
/>,
);
expect(screen.getByTestId('data-spillage-action')).toBeInTheDocument();
expect(screen.getByTestId('data-spillage-action-remove-message')).toBeInTheDocument();

View file

@ -1,11 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import './data_spillage_actions.scss';
import {useDispatch} from 'react-redux';
import './data_spillage_actions.scss';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {closeModal, openModal} from 'actions/views/modals';
import KeepRemoveFlaggedMessageConfirmationModal
from 'components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
flaggedPost: Post;
reportingUser: UserProfile;
}
export default function DataSpillageAction({flaggedPost, reportingUser}: Props) {
const dispatch = useDispatch();
const handleRemoveMessage = useCallback(() => {
const data = {
modalId: ModalIdentifiers.REMOVE_FLAGGED_POST,
dialogType: KeepRemoveFlaggedMessageConfirmationModal,
dialogProps: {
flaggedPost,
reportingUser,
action: 'remove' as const,
onExited: () => closeModal(ModalIdentifiers.REMOVE_FLAGGED_POST),
},
};
dispatch(openModal(data));
}, [dispatch, flaggedPost, reportingUser]);
const handleKeepMessage = useCallback(() => {
const data = {
modalId: ModalIdentifiers.REMOVE_FLAGGED_POST,
dialogType: KeepRemoveFlaggedMessageConfirmationModal,
dialogProps: {
flaggedPost,
reportingUser,
action: 'keep' as const,
onExited: () => closeModal(ModalIdentifiers.REMOVE_FLAGGED_POST),
},
};
dispatch(openModal(data));
}, [dispatch, flaggedPost, reportingUser]);
export default function DataSpillageAction() {
return (
<div
className='DataSpillageAction'
@ -14,6 +62,7 @@ export default function DataSpillageAction() {
<button
className='btn btn-danger btn-sm'
data-testid='data-spillage-action-remove-message'
onClick={handleRemoveMessage}
>
<FormattedMessage
id='data_spillage_report.remove_message.button_text'
@ -24,6 +73,7 @@ export default function DataSpillageAction() {
<button
className='btn btn-tertiary btn-sm'
data-testid='data-spillage-action-keep-message'
onClick={handleKeepMessage}
>
<FormattedMessage
id='data_spillage_report.keep_message.button_text'

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {selectPostFromRightHandSideSearch} from 'actions/views/rhs';
type Props = {
post: Post;
}
export default function DataSpillageFooter({post}: Props) {
const dispatch = useDispatch();
const onClick = useCallback(() => {
if (post) {
dispatch(selectPostFromRightHandSideSearch(post));
}
}, [dispatch, post]);
return (
<div
className='DataSpillageFooter'
data-testid='data-spillage-footer'
>
<button
className='btn btn-primary btn-sm'
data-testid='data-spillage-action-view-details'
onClick={onClick}
>
<FormattedMessage
id='data_spillage_report.view_details.button_text'
defaultMessage='View details'
/>
</button>
</div>
);
}

View file

@ -1,18 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react';
import React from 'react';
import type {Post} from '@mattermost/types/posts';
import type {DeepPartial} from '@mattermost/types/utilities';
import DataSpillageReport from 'components/post_view/data_spillage_report/data_spillage_report';
import {Client4} from 'mattermost-redux/client';
import {DataSpillageReport} from 'components/post_view/data_spillage_report/data_spillage_report';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
jest.mock('components/common/hooks/useUser');
jest.mock('components/common/hooks/useChannel');
jest.mock('components/common/hooks/usePost');
jest.mock('mattermost-redux/actions/posts');
jest.mock('components/common/hooks/useContentFlaggingFields');
jest.mock('mattermost-redux/client');
const mockedUseUser = require('components/common/hooks/useUser').useUser as jest.MockedFunction<any>;
const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
const mockGetPost = require('mattermost-redux/actions/posts').getPost as jest.MockedFunction<any>;
const useContentFlaggingFields = require('components/common/hooks/useContentFlaggingFields').useContentFlaggingFields as jest.MockedFunction<any>;
const usePostContentFlaggingValues = require('components/common/hooks/useContentFlaggingFields').usePostContentFlaggingValues as jest.MockedFunction<any>;
const mockedClient4 = jest.mocked(Client4);
describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
const reportingUser = TestHelper.getUserMock({
id: 'ewgposajm3fwpjbqu1t6scncia',
@ -57,11 +77,6 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
[reportedPostAuthor.id]: reportedPostAuthor,
},
},
posts: {
posts: {
[reportedPost.id]: reportedPost,
},
},
channels: {
channels: {
[reportedPostChannel.id]: reportedPostChannel,
@ -75,6 +90,231 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
},
};
const contentFlaggingFields = {
action_time: {
id: 'wcdwx96ratrrbq5uuhyz3zq48r',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'action_time',
type: 'text',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661623,
update_at: 1756788661623,
delete_at: 0,
},
actor_comment: {
id: 'f3s8fsgn978bbne96s6tqpdife',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'actor_comment',
type: 'text',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661624,
update_at: 1756788661624,
delete_at: 0,
},
actor_user_id: {
id: 'z1bpj14kgfdjzmnuyy6oqcyufh',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'actor_user_id',
type: 'user',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661626,
update_at: 1756788661626,
delete_at: 0,
},
flagged_post_id: {
id: 'jssh4fbn9jrfxjf4fsr7zdu65y',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'flagged_post_id',
type: 'text',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661623,
update_at: 1756788661623,
delete_at: 0,
},
reporting_comment: {
id: 'sx7h53tdsbfb985edkmze71j3c',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reporting_comment',
type: 'text',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661625,
update_at: 1756788661625,
delete_at: 0,
},
reporting_reason: {
id: '5knyqectdfbi98rab3zz4hsyhh',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reporting_reason',
type: 'select',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661624,
update_at: 1756788661624,
delete_at: 0,
},
reporting_time: {
id: '5cib5g3ag3gs3gxyg7awjd6csh',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reporting_time',
type: 'text',
attrs: {
subType: 'timestamp',
},
target_id: '',
target_type: '',
create_at: 1756788661625,
update_at: 1756788661625,
delete_at: 0,
},
reporting_user_id: {
id: '1is7ir68bp8nup3rr1pp6d7fsr',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reporting_user_id',
type: 'user',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661625,
update_at: 1756788661625,
delete_at: 0,
},
reviewer_user_id: {
id: 'g6hrg3uugbyqzyyb9kx8jgpbwh',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'reviewer_user_id',
type: 'user',
attrs: null,
target_id: '',
target_type: '',
create_at: 1756788661624,
update_at: 1756788661624,
delete_at: 0,
},
status: {
id: 'kd9n7tf9n3ynjczqpkpjkbzgoh',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
name: 'status',
type: 'select',
attrs: {
options: [
{
color: 'light_grey',
name: 'Pending',
},
{
color: 'dark_blue',
name: 'Assigned',
},
{
color: 'dark_red',
name: 'Removed',
},
{
color: 'light_blue',
name: 'Retained',
},
],
},
target_id: '',
target_type: '',
create_at: 1756788661623,
update_at: 1756788661623,
delete_at: 0,
},
};
const postContentFlaggingValues = [
{
id: 'cnth3s1rot88zpz3hwy99uet7y',
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: 'kd9n7tf9n3ynjczqpkpjkbzgoh',
value: 'Pending',
create_at: 1756790533486,
update_at: 1756790533486,
delete_at: 0,
},
{
id: 'gg5aq8iefpn978po54fd9xf1br',
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '5knyqectdfbi98rab3zz4hsyhh',
value: 'Sensitive data',
create_at: 1756790533487,
update_at: 1756790533487,
delete_at: 0,
},
{
id: 'nbooe396mf8zjq5pjk931gtf5y',
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '1is7ir68bp8nup3rr1pp6d7fsr',
value: reportingUser.id,
create_at: 1756790533487,
update_at: 1756790533487,
delete_at: 0,
},
{
id: 'dxzrb4g9xfn5jn5mgxcnmqauzo',
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: '5cib5g3ag3gs3gxyg7awjd6csh',
value: 1756790533486,
create_at: 1756790533488,
update_at: 1756790533488,
delete_at: 0,
},
{
id: 'mx4ez9di7bgebfa8y8r5uodjkc',
target_id: 'i93oo5gb4tygixs4g8atqyjryy',
target_type: 'post',
group_id: 'ey36rkw3bjybb8gtrdkn3hmeqa',
field_id: 'sx7h53tdsbfb985edkmze71j3c',
value: 'Please review this post for potential violations',
create_at: 1756790533488,
update_at: 1756790533488,
delete_at: 0,
},
];
beforeEach(() => {
jest.resetAllMocks();
// Mock all hooks before any component rendering
mockedUseUser.mockImplementation((userId: string) => {
if (userId === reportingUser.id) {
return reportingUser;
} else if (userId === reportedPostAuthor.id) {
return reportedPostAuthor;
}
return null;
});
mockedUsePost.mockReturnValue(null);
mockUseChannel.mockReturnValue(reportedPostChannel);
// Mock the action to return a resolved promise instead of dispatching
mockGetPost.mockResolvedValue({type: 'MOCK_ACTION', data: reportedPost});
useContentFlaggingFields.mockReturnValue(contentFlaggingFields);
usePostContentFlaggingValues.mockReturnValue(postContentFlaggingValues);
mockedClient4.getFlaggedPost = jest.fn().mockResolvedValue(reportedPost);
});
it('should render selected fields when not in RHS', async () => {
renderWithContext(
<DataSpillageReport
@ -84,6 +324,8 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
baseState,
);
await act(async () => {});
// validate title
const title = screen.queryByTestId('property-card-title');
expect(title).toBeVisible();
@ -94,10 +336,10 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
expect(screen.queryAllByTestId('select-property')).toHaveLength(2);
const statusFieldValue = screen.queryAllByTestId('select-property')[0];
expect(statusFieldValue).toHaveTextContent('Flag dismissed');
expect(statusFieldValue).toHaveTextContent('Pending');
const reasonFieldValue = screen.queryAllByTestId('select-property')[1];
expect(reasonFieldValue).toHaveTextContent('Inappropriate content');
expect(reasonFieldValue).toHaveTextContent('Sensitive data');
const postPreview = screen.queryByTestId('post-preview-property');
expect(postPreview).toBeVisible();
@ -119,11 +361,17 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
baseState,
);
await act(async () => {});
const flaggedBy = screen.queryAllByTestId('user-property')[0];
expect(flaggedBy).toBeVisible();
expect(flaggedBy).toHaveTextContent('reporting_user');
const comment = screen.queryByTestId('text-property');
const postId = screen.queryAllByTestId('text-property')[0];
expect(postId).toBeVisible();
expect(postId).toHaveTextContent(reportedPost.id);
const comment = screen.queryAllByTestId('text-property')[1];
expect(comment).toBeVisible();
expect(comment).toHaveTextContent('Please review this post for potential violations');
@ -135,10 +383,6 @@ describe('components/post_view/data_spillage_report/DataSpillageReport', () => {
expect(team).toBeVisible();
expect(team).toHaveTextContent('Reported Post Team');
const postedBy = screen.queryAllByTestId('user-property')[1];
expect(postedBy).toBeVisible();
expect(postedBy).toHaveTextContent('reported_post_author');
const reportedAt = screen.queryAllByTestId('timestamp-property')[0];
expect(reportedAt).toBeVisible();

View file

@ -1,361 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useDispatch} from 'react-redux';
import {ContentFlaggingStatus} from '@mattermost/types/content_flagging';
import type {Post} from '@mattermost/types/posts';
import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {Client4} from 'mattermost-redux/client';
import AtMention from 'components/at_mention';
import {useChannel} from 'components/common/hooks/useChannel';
import {usePost} from 'components/common/hooks/usePost';
import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components/common/hooks/useContentFlaggingFields';
import {useUser} from 'components/common/hooks/useUser';
import DataSpillageAction from 'components/post_view/data_spillage_report/data_spillage_actions/data_spillage_actions';
import type {PropertiesCardViewMetadata} from 'components/properties_card_view/properties_card_view';
import PropertiesCardView from 'components/properties_card_view/properties_card_view';
import {DataSpillagePropertyNames} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './data_spillage_report.scss';
import DataSpillageFooter from './data_spillage_footer/data_spillage_footer';
import {getSyntheticPropertyFields, getSyntheticPropertyValues} from './synthetic_data';
// TODO: this function will be replaced with actual data fetched from API in a later PR
function getDummyPropertyFields(): PropertyField[] {
return [
{
id: 'status_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Status,
type: 'select',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
attrs: {
editable: false,
options: [
{
id: 'option_pending_review',
name: 'Pending review',
color: 'light_gray',
},
{
id: 'option_reviewer_assigned',
name: 'Reviewer assigned',
color: 'light_blue',
},
{
id: 'option_dismissed',
name: 'Flag dismissed',
color: 'dark_blue',
},
{
id: 'option_removed',
name: 'Removed',
color: 'dark_red',
},
],
},
},
{
id: 'reporting_user_id_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.FlaggedBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reason_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Reason,
type: 'select',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'comment_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Comment,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ReportingTime,
type: 'text',
attrs: {subType: 'timestamp'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reviewer_user_id_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ReviewingUser,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
attrs: {
editable: true,
},
},
{
id: 'actor_user_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'actor_comment_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionComment,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'action_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.ActionTime,
type: 'text',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_preview_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Message,
type: 'text',
attrs: {subType: 'post'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedIn,
type: 'text',
attrs: {subType: 'channel'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'team_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.Team,
type: 'text',
attrs: {subType: 'team'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_author_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedBy,
type: 'user',
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_creation_time_field_id',
group_id: 'content_flagging_group_id',
name: DataSpillagePropertyNames.PostedAt,
type: 'text',
attrs: {subType: 'timestamp'},
target_type: 'post',
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
}
// TODO: this function will be replaced with actual data fetched from API in a later PR
function getDummyPropertyValues(postId: string, channelId: string, teamId: string, authorId: string, postCreateAt: number): Array<PropertyValue<unknown>> {
return [
{
id: 'status_value_id',
field_id: 'status_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Flag dismissed',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_user_value_id',
field_id: 'reporting_user_id_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'ewgposajm3fwpjbqu1t6scncia',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reason_value_id',
field_id: 'reason_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Inappropriate content',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'comment_value_id',
field_id: 'comment_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: 'Please review this post for potential violations.',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reporting_time_value_id',
field_id: 'reporting_time_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: new Date(2025, 0, 1, 0, 1, 0, 0).getTime(),
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_preview_value_id',
field_id: 'post_preview_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: postId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_value_id',
field_id: 'channel_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: channelId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'team_value_id',
field_id: 'team_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: teamId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_author_value_id',
field_id: 'post_author_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: authorId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_creation_time_value_id',
field_id: 'post_creation_time_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: postCreateAt,
create_at: 0,
update_at: 0,
delete_at: 0,
},
// No reviewer assigned yet
{
id: 'reviewer_user_value_id',
field_id: 'reviewer_user_id_field_id',
target_id: 'reported_post_id',
target_type: 'post',
group_id: 'content_flagging_group_id',
value: '',
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
}
const fieldOrder = [
'status_field_id',
'reason_field_id',
'post_preview_field_id',
'reporting_user_id_field_id',
'comment_field_id',
'reporting_time_field_id',
'reviewer_user_id_field_id',
'actor_user_field_id',
'actor_comment_field_id',
'action_time_field_id',
'channel_field_id',
'team_field_id',
'post_author_field_id',
'post_creation_time_field_id',
// The order of fields to be displayed in the report, from top to bottom.
const orderedFieldName = [
'status',
'reporting_reason',
'actor_user_id',
'actor_comment',
'action_time',
'post_preview',
'post_id',
'reviewer',
'reporting_user_id',
'reporting_time',
'reporting_comment',
'channel',
'team',
'post_author',
'post_creation_time',
];
const shortModeFieldOrder = [
'status_field_id',
'reason_field_id',
'post_preview_field_id',
'reviewer_user_id_field_id',
'status',
'reporting_reason',
'post_preview',
'reviewer',
];
type Props = {
@ -363,37 +56,66 @@ type Props = {
isRHS?: boolean;
};
export default function DataSpillageReport({post, isRHS}: Props) {
const dispatch = useDispatch();
export function DataSpillageReport({post, isRHS}: Props) {
const {formatMessage} = useIntl();
const [propertyFields, setPropertyFields] = useState<PropertyField[]>([]);
const [propertyValues, setPropertyValues] = useState<Array<PropertyValue<unknown>>>([]);
const loaded = useRef(false);
const dispatch = useDispatch();
const reportedPostId = post.props.reported_post_id as string;
const reportedPost = usePost(reportedPostId);
const naturalPropertyFields = useContentFlaggingFields('fetch');
const naturalPropertyValues = usePostContentFlaggingValues(reportedPostId);
const [reportedPost, setReportedPost] = useState<Post>();
const channel = useChannel(reportedPost?.channel_id || '');
const reportingUserFieldId = propertyFields.find((field) => field.name === DataSpillagePropertyNames.FlaggedBy);
useEffect(() => {
const work = async () => {
if (!loaded.current && !reportedPost) {
// We need to obtain the post directly from action bypassing the selectors
// because the post might be soft-deleted and the post reducers do not store deleted posts
// in the store.
const post = await loadFlaggedPost(reportedPostId);
if (post) {
setReportedPost(post);
loaded.current = true;
}
}
};
work();
}, [dispatch, reportedPost, reportedPostId]);
const propertyFields = useMemo((): NameMappedPropertyFields => {
if (!naturalPropertyFields || !Object.keys(naturalPropertyFields).length) {
return {};
}
const syntheticFields = getSyntheticPropertyFields(naturalPropertyFields.status.group_id);
return {...naturalPropertyFields, ...syntheticFields};
}, [naturalPropertyFields]);
const propertyValues = useMemo((): Array<PropertyValue<unknown>> => {
if (!naturalPropertyValues || !naturalPropertyValues.length) {
return [];
}
const syntheticValues = getSyntheticPropertyValues(
naturalPropertyValues[0].group_id,
reportedPostId,
reportedPost?.channel_id || '',
channel?.team_id || '',
reportedPost?.user_id || '',
reportedPost?.create_at || 0,
);
return [...naturalPropertyValues, ...syntheticValues];
}, [channel?.team_id, naturalPropertyValues, reportedPost?.channel_id, reportedPost?.create_at, reportedPost?.user_id, reportedPostId]);
const reportingUserFieldId = propertyFields[DataSpillagePropertyNames.FlaggedBy];
const reportingUserIdValue = propertyValues.find((value) => value.field_id === reportingUserFieldId?.id);
const reportingUser = useSelector((state: GlobalState) => getUser(state, reportingUserIdValue ? reportingUserIdValue.value as string : ''));
useEffect(() => {
if (!reportingUser && reportingUserIdValue && reportedPost) {
dispatch(getMissingProfilesByIds([
reportingUserIdValue.value as string,
reportedPost.user_id,
]));
}
}, [dispatch, reportedPost, reportingUser, reportingUserIdValue]);
useEffect(() => {
if (reportedPost && channel) {
// TODO: this function will be replaced with actual data fetched from API in a later PR
setPropertyFields(getDummyPropertyFields());
setPropertyValues(getDummyPropertyValues(reportedPostId, reportedPost.channel_id, channel.team_id, reportedPost.user_id, post.create_at));
}
}, [reportedPost, reportedPostId, channel, post.create_at]);
const reportingUserId = reportingUserIdValue ? reportingUserIdValue.value as string : '';
const reportingUser = useUser(reportingUserId);
const title = formatMessage({
id: 'data_spillage_report_post.title',
@ -404,6 +126,46 @@ export default function DataSpillageReport({post, isRHS}: Props) {
const mode = isRHS ? 'full' : 'short';
const metadata = useMemo<PropertiesCardViewMetadata>(() => {
return {
post_preview: {
getPost: loadFlaggedPost,
fetchDeletedPost: true,
},
reporting_comment: {
placeholder: formatMessage({id: 'data_spillage_report_post.reporting_comment.placeholder', defaultMessage: 'No comment'}),
},
};
}, [formatMessage]);
const footer = useMemo(() => {
if (isRHS) {
return null;
}
return (<DataSpillageFooter post={post}/>);
}, [isRHS, post]);
const actionRow = useMemo(() => {
if (!reportedPost || !reportingUser) {
return null;
}
let showActionRow;
if (!propertyFields || !propertyValues) {
showActionRow = true;
} else {
const status = propertyValues.find((value) => value.field_id === propertyFields.status.id)?.value as string | undefined;
showActionRow = reportedPost && reportingUser && status && (status === ContentFlaggingStatus.Pending || status === ContentFlaggingStatus.Assigned);
}
return showActionRow ? (
<DataSpillageAction
flaggedPost={reportedPost}
reportingUser={reportingUser}
/>) : null;
}, [propertyFields, propertyValues, reportedPost, reportingUser]);
return (
<div
className={`DataSpillageReport mode_${mode}`}
@ -414,11 +176,17 @@ export default function DataSpillageReport({post, isRHS}: Props) {
title={title}
propertyFields={propertyFields}
propertyValues={propertyValues}
fieldOrder={fieldOrder}
fieldOrder={orderedFieldName}
shortModeFieldOrder={shortModeFieldOrder}
actionsRow={<DataSpillageAction/>}
actionsRow={actionRow}
mode={mode}
metadata={metadata}
footer={footer}
/>
</div>
);
}
async function loadFlaggedPost(postId: string) {
return Client4.getFlaggedPost(postId);
}

View file

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
export function getSyntheticPropertyFields(groupId: string): NameMappedPropertyFields {
return {
post_id: {
id: 'post_id_field_id',
group_id: groupId,
name: 'post_id',
type: 'text',
attrs: {},
create_at: 0,
update_at: 0,
delete_at: 0,
},
post_preview: {
id: 'post_preview_field_id',
group_id: groupId,
name: 'post_preview',
type: 'text',
attrs: {subType: 'post'},
create_at: 0,
update_at: 0,
delete_at: 0,
},
reviewer: {
id: 'reviewer_field_id',
group_id: groupId,
name: 'reviewer',
type: 'user',
attrs: {editable: true},
create_at: 0,
update_at: 0,
delete_at: 0,
},
channel: {
id: 'channel_field_id',
group_id: groupId,
name: 'channel',
type: 'text',
attrs: {subType: 'channel'},
create_at: 0,
update_at: 0,
delete_at: 0,
},
team: {
id: 'team_field_id',
group_id: groupId,
name: 'team',
type: 'text',
attrs: {subType: 'team'},
create_at: 0,
update_at: 0,
delete_at: 0,
},
post_author: {
id: 'post_author_field_id',
group_id: groupId,
name: 'post_author',
type: 'user',
attrs: {},
create_at: 0,
update_at: 0,
delete_at: 0,
},
post_creation_time: {
id: 'post_creation_time_field_id',
group_id: groupId,
name: 'post_creation_time',
type: 'text',
attrs: {subType: 'timestamp'},
create_at: 0,
update_at: 0,
delete_at: 0,
},
};
}
export function getSyntheticPropertyValues(groupId: string, reportedPostId: string, channelId: string, teamId: string, postAuthorId: string, postCreateAt: number): Array<PropertyValue<unknown>> {
return [
{
id: 'post_preview_value_id',
field_id: 'post_preview_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: reportedPostId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_id_value_id',
field_id: 'post_id_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: reportedPostId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'reviewer_user_value_id',
field_id: 'reviewer_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: '',
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'channel_value_id',
field_id: 'channel_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: channelId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'team_value_id',
field_id: 'team_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: teamId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_author_value_id',
field_id: 'post_author_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: postAuthorId,
create_at: 0,
update_at: 0,
delete_at: 0,
},
{
id: 'post_creation_time_value_id',
field_id: 'post_creation_time_field_id',
target_id: reportedPostId,
target_type: 'post',
group_id: groupId,
value: postCreateAt,
create_at: 0,
update_at: 0,
delete_at: 0,
},
];
}

View file

@ -28,6 +28,7 @@ export type OwnProps = {
metadata: PostPreviewMetadata;
preventClickAction?: boolean;
previewFooterMessage?: string;
usePostAsSource?: boolean;
}
function makeMapStateToProps() {
@ -39,7 +40,7 @@ function makeMapStateToProps() {
let user = null;
let embedVisible = false;
let channelDisplayName = ownProps.metadata.channel_display_name;
const previewPost = getPost(state, ownProps.metadata.post_id);
const previewPost = ownProps.metadata.post || getPost(state, ownProps.metadata.post_id);
if (previewPost && previewPost.user_id) {
user = getUser(state, previewPost.user_id);

View file

@ -62,6 +62,7 @@ const PostMessagePreview = (props: Props) => {
compactDisplay={compactDisplay}
isInPermalink={true}
handleFileDropdownOpened={handleFileDropdownOpened}
usePostAsSource={props.usePostAsSource}
/>
);
}

View file

@ -18,11 +18,17 @@ import ShowMore from 'components/post_view/show_more';
import type {AttachmentTextOverflowType} from 'components/post_view/show_more/show_more';
import Pluggable from 'plugins/pluggable';
import {PostTypes} from 'utils/constants';
import type {TextFormattingOptions} from 'utils/text_formatting';
import * as Utils from 'utils/utils';
import type {PostPluginComponent} from 'types/store/plugins';
// These posts types must not be rendered with the collapsible "Show More" container.
const FULL_HEIGHT_POST_TYPES = new Set([
PostTypes.CUSTOM_DATA_SPILLAGE_REPORT,
]);
type Props = {
post: Post; /* The post to render the message for */
enableFormatting?: boolean; /* Set to enable Markdown formatting */
@ -150,13 +156,8 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
const channel = getChannel(store.getState(), post.channel_id);
const isSharedChannel = channel?.shared || false;
return (
<ShowMore
checkOverflow={this.state.checkOverflow}
text={message}
overflowType={overflowType}
maxHeight={maxHeight}
>
const body = (
<>
<div
id={id}
className='post-message__text'
@ -180,6 +181,21 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
onHeightChange={this.handleHeightReceived}
/>
)}
</>
);
if (FULL_HEIGHT_POST_TYPES.has(postType)) {
return body;
}
return (
<ShowMore
checkOverflow={this.state.checkOverflow}
text={message}
overflowType={overflowType}
maxHeight={maxHeight}
>
{body}
</ShowMore>
);
}

View file

@ -2,56 +2,144 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {defineMessages, FormattedMessage} from 'react-intl';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {Post} from '@mattermost/types/posts';
import type {
NameMappedPropertyFields,
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import PropertyValueRenderer from './propertyValueRenderer/propertyValueRenderer';
import './properties_card_view.scss';
export type PostPreviewFieldMetadata = {
getPost?: (postId: string) => Promise<Post>;
fetchDeletedPost?: boolean;
};
export type TextFieldMetadata = {
placeholder?: string;
};
export type FieldMetadata = PostPreviewFieldMetadata | TextFieldMetadata;
export type PropertiesCardViewMetadata = {
[key: string]: FieldMetadata;
}
type OrderedRow = {
field: PropertyField;
value: PropertyValue<unknown>;
};
const fieldNameMessages = defineMessages({
status: {
id: 'property_card.field.status.label',
defaultMessage: 'Status',
},
reporting_reason: {
id: 'property_card.field.reporting_reason.label',
defaultMessage: 'Reason',
},
post_preview: {
id: 'property_card.field.post_preview.label',
defaultMessage: 'Message',
},
post_id: {
id: 'property_card.field.post_id.label',
defaultMessage: 'Post ID',
},
reviewer: {
id: 'property_card.field.reviewer_user_id.label',
defaultMessage: 'Reviewer',
},
reporting_user_id: {
id: 'property_card.field.reporting_user_id.label',
defaultMessage: 'Flagged by',
},
reporting_comment: {
id: 'property_card.field.reporting_comment.label',
defaultMessage: 'Comment',
},
channel: {
id: 'property_card.field.channel.label',
defaultMessage: 'Channel',
},
team: {
id: 'property_card.field.team.label',
defaultMessage: 'Team',
},
post_author: {
id: 'property_card.field.post_author.label',
defaultMessage: 'Posted by',
},
post_creation_time: {
id: 'property_card.field.post_creation_time.label',
defaultMessage: 'Posted at',
},
reporting_time: {
id: 'property_card.field.reporting_time.label',
defaultMessage: 'Flagged at',
},
actor_user_id: {
id: 'property_card.field.actor_user_id.label',
defaultMessage: 'Reviewed by',
},
action_time: {
id: 'property_card.field.action_time.label',
defaultMessage: 'Reviewed at',
},
actor_comment: {
id: 'property_card.field.actor_comment.label',
defaultMessage: 'Reviewer\'s comment',
},
});
type Props = {
title: React.ReactNode;
propertyFields: PropertyField[];
propertyFields: NameMappedPropertyFields;
fieldOrder: Array<PropertyField['id']>;
shortModeFieldOrder: Array<PropertyField['id']>;
propertyValues: Array<PropertyValue<unknown>>;
mode?: 'short' | 'full';
actionsRow?: React.ReactNode;
metadata?: PropertiesCardViewMetadata;
footer?: React.ReactNode;
}
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow}: Props) {
const orderedRows = useMemo<Array<{field: PropertyField; value: PropertyValue<unknown>}>>(() => {
if (!propertyFields.length || !fieldOrder.length || !propertyValues.length) {
export default function PropertiesCardView({title, propertyFields, fieldOrder, shortModeFieldOrder, propertyValues, mode, actionsRow, metadata, footer}: Props) {
const orderedRows = useMemo<OrderedRow[]>(() => {
const hasRequiredData =
Object.keys(propertyFields).length > 0 &&
fieldOrder.length > 0 &&
propertyValues.length > 0;
if (!hasRequiredData) {
return [];
}
const fieldsById = propertyFields.reduce((acc, field) => {
acc[field.id] = field;
return acc;
}, {} as {[key: string]: PropertyField});
// Create lookup map for efficient value retrieval
const valuesByFieldId = new Map(
propertyValues.map((value) => [value.field_id, value]),
);
const valuesByFieldId = propertyValues.reduce((acc, value) => {
acc[value.field_id] = value;
return acc;
}, {} as {[key: string]: PropertyValue<unknown>});
// Determine which field order to use
const currentFieldOrder = mode === 'short' ? shortModeFieldOrder : fieldOrder;
const fieldOrderToUse = mode === 'short' ? shortModeFieldOrder : fieldOrder;
return fieldOrderToUse.map((fieldId) => {
const field = fieldsById[fieldId];
const value = valuesByFieldId[fieldId];
// Build ordered rows, filtering out incomplete entries
return currentFieldOrder.
map((fieldName) => {
const field = propertyFields[fieldName];
const value = field ? valuesByFieldId.get(field.id) : undefined;
return {
field,
value,
};
}).filter((entry) => Boolean(entry.value));
return field && value ? {field, value} : null;
}).
filter((row): row is OrderedRow => row !== null);
}, [fieldOrder, mode, propertyFields, propertyValues, shortModeFieldOrder]);
if (orderedRows.length === 0) {
return null;
}
return (
<div
className='PropertyCardView'
@ -67,6 +155,8 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
<div className='PropertyCardView_fields'>
{
orderedRows.map(({field, value}) => {
const translation = fieldNameMessages[field.name as keyof typeof fieldNameMessages];
return (
<div
key={field.id}
@ -74,13 +164,14 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
data-testid='property-card-row'
>
<div className='field'>
{field.name}
{translation ? <FormattedMessage {...translation}/> : field.name}
</div>
<div className='value'>
<PropertyValueRenderer
field={field}
value={value}
metadata={metadata ? metadata[field.name] : undefined}
/>
</div>
</div>
@ -103,6 +194,8 @@ export default function PropertiesCardView({title, propertyFields, fieldOrder, s
</div>
</div>
}
{footer}
</div>
</div>
);

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
@ -8,19 +9,26 @@ import type {Post} from '@mattermost/types/posts';
import type {PropertyValue} from '@mattermost/types/properties';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {renderWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import PostPreviewPropertyRenderer from './post_preview_property_renderer';
jest.mock('components/common/hooks/usePost');
jest.mock('components/common/hooks/useChannel');
jest.mock('components/common/hooks/use_team');
jest.mock('components/common/hooks/usePost');
jest.mock('mattermost-redux/client');
const mockUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
const mockUseChannel = require('components/common/hooks/useChannel').useChannel as jest.MockedFunction<any>;
const mockUseTeam = require('components/common/hooks/use_team').useTeam as jest.MockedFunction<any>;
const mockedUsePost = require('components/common/hooks/usePost').usePost as jest.MockedFunction<any>;
const mockedClient4 = jest.mocked(Client4);
describe('PostPreviewPropertyRenderer', () => {
const mockUser: UserProfile = {
@ -59,9 +67,13 @@ describe('PostPreviewPropertyRenderer', () => {
value: {
value: 'post-id-123',
} as PropertyValue<string>,
metadata: {
fetchDeletedPost: true,
getPost: (postId: string) => Client4.getFlaggedPost(postId),
},
};
const baseState = {
const baseState: DeepPartial<GlobalState> = {
entities: {
users: {
profiles: {
@ -69,11 +81,6 @@ describe('PostPreviewPropertyRenderer', () => {
},
currentUserId: mockUser.id,
},
posts: {
posts: {
[mockPost.id]: mockPost,
},
},
channels: {
channels: {
[mockChannel.id]: mockChannel,
@ -90,15 +97,16 @@ describe('PostPreviewPropertyRenderer', () => {
preferences: {
myPreferences: {},
},
posts: {posts: {}},
},
};
beforeEach(() => {
jest.clearAllMocks();
mockedUsePost.mockReturnValue(null);
mockedClient4.getFlaggedPost.mockResolvedValue(mockPost);
});
it('should render PostMessagePreview when all data is available', () => {
mockUsePost.mockReturnValue(mockPost);
it('should render PostMessagePreview when all data is available', async () => {
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
@ -107,26 +115,29 @@ describe('PostPreviewPropertyRenderer', () => {
baseState,
);
expect(getByTestId('post-preview-property')).toBeVisible();
await waitFor(() => {
expect(getByTestId('post-preview-property')).toBeVisible();
});
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
});
it('should return null when post is not found', () => {
mockUsePost.mockReturnValue(null);
it('should return null when post is not found', async () => {
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
mockedClient4.getFlaggedPost.mockRejectedValue({message: 'Post not found'});
const {container} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
baseState,
);
await act(async () => {});
expect(container.firstChild).toBeNull();
});
it('should return null when channel is not found', () => {
mockUsePost.mockReturnValue(mockPost);
it('should return null when channel is not found', async () => {
mockUseChannel.mockReturnValue(null);
mockUseTeam.mockReturnValue(mockTeam);
@ -135,11 +146,11 @@ describe('PostPreviewPropertyRenderer', () => {
baseState,
);
await act(async () => {});
expect(container.firstChild).toBeNull();
});
it('should return null when team is not found', () => {
mockUsePost.mockReturnValue(mockPost);
it('should return null when team is not found', async () => {
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(null);
@ -148,16 +159,16 @@ describe('PostPreviewPropertyRenderer', () => {
baseState,
);
await act(async () => {});
expect(container.firstChild).toBeNull();
});
it('should handle private channel', () => {
it('should handle private channel', async () => {
const privateChannel = {
...mockChannel,
type: 'P' as const,
};
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(privateChannel);
mockUseTeam.mockReturnValue(mockTeam);
@ -166,12 +177,13 @@ describe('PostPreviewPropertyRenderer', () => {
baseState,
);
await act(async () => {});
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
});
it('should handle missing display names gracefully', () => {
it('should handle missing display names gracefully', async () => {
const channelWithoutDisplayName = {
...mockChannel,
display_name: '',
@ -182,7 +194,6 @@ describe('PostPreviewPropertyRenderer', () => {
name: '',
};
mockUsePost.mockReturnValue(mockPost);
mockUseChannel.mockReturnValue(channelWithoutDisplayName);
mockUseTeam.mockReturnValue(teamWithoutName);
@ -191,12 +202,13 @@ describe('PostPreviewPropertyRenderer', () => {
baseState,
);
await act(async () => {});
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Test post message')).toBeVisible();
expect(getByText('Originally posted in ~')).toBeVisible();
});
it('should handle post with file attachments', () => {
it('should handle post with file attachments', async () => {
const postWithAttachments = {
...mockPost,
message: 'Post with file attachment',
@ -212,63 +224,31 @@ describe('PostPreviewPropertyRenderer', () => {
},
{
id: 'file-id-2',
name: 'image.jpg',
extension: 'jpg',
name: 'file.txt',
extension: 'txt',
size: 512000,
mime_type: 'image/jpeg',
mime_type: 'text/plain;charset=UTF-8',
},
],
},
};
} as Post;
const stateWithFiles = {
...baseState,
entities: {
...baseState.entities,
posts: {
posts: {
[postWithAttachments.id]: postWithAttachments,
},
},
files: {
fileIdsByPostId: {
[postWithAttachments.id]: ['file-id-1', 'file-id-2'],
},
files: {
'file-id-1': {
id: 'file-id-1',
name: 'document.pdf',
extension: 'pdf',
size: 1024000,
mime_type: 'application/pdf',
},
'file-id-2': {
id: 'file-id-2',
name: 'image.jpg',
extension: 'jpg',
size: 512000,
mime_type: 'image/jpeg',
},
},
},
},
};
mockUsePost.mockReturnValue(postWithAttachments);
mockedClient4.getFlaggedPost.mockResolvedValue(postWithAttachments);
mockUseChannel.mockReturnValue(mockChannel);
mockUseTeam.mockReturnValue(mockTeam);
const {getByTestId, getByText} = renderWithContext(
<PostPreviewPropertyRenderer {...defaultProps}/>,
stateWithFiles,
baseState,
);
await act(async () => {});
expect(getByTestId('post-preview-property')).toBeVisible();
expect(getByText('Post with file attachment')).toBeVisible();
expect(getByText('Originally posted in ~Test Channel')).toBeVisible();
// Assert that file attachments are visible
expect(getByText('document.pdf')).toBeVisible();
expect(getByText('image.jpg')).toBeVisible();
expect(getByText('file.txt')).toBeVisible();
});
});

View file

@ -1,28 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import type {PostPreviewMetadata} from '@mattermost/types/posts';
import type {Post, PostPreviewMetadata} from '@mattermost/types/posts';
import type {PropertyValue} from '@mattermost/types/properties';
import {useTeam} from 'components/common/hooks/use_team';
import {useChannel} from 'components/common/hooks/useChannel';
import {usePost} from 'components/common/hooks/usePost';
import PostMessagePreview from 'components/post_view/post_message_preview';
import type {PostPreviewFieldMetadata} from 'components/properties_card_view/properties_card_view';
const noop = () => {};
type Props = {
value: PropertyValue<unknown>;
metadata?: PostPreviewFieldMetadata;
}
export default function PostPreviewPropertyRenderer({value}: Props) {
const post = usePost(value.value as string);
export default function PostPreviewPropertyRenderer({value, metadata}: Props) {
const postId = value.value as string;
const [post, setPost] = useState<Post>();
const channel = useChannel(post?.channel_id || '');
const team = useTeam(channel?.team_id || '');
const loaded = useRef(false);
const postFromStore = usePost(postId);
useEffect(() => {
const usePostFromStore = Boolean(!metadata?.getPost && postFromStore);
const allowDeletedPost = postFromStore?.delete_at !== 0 && !metadata?.fetchDeletedPost;
if (usePostFromStore && allowDeletedPost) {
setPost(postFromStore);
loaded.current = true;
return;
}
const loadPost = async () => {
const canLoadPost = metadata?.getPost && !loaded.current && !post;
if (!canLoadPost) {
return;
}
try {
const fetchedPost = await metadata.getPost!(postId);
if (fetchedPost) {
setPost(fetchedPost);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log('Error occurred while fetching post for post preview property renderer', error);
} finally {
loaded.current = true;
}
};
loadPost();
}, [metadata, post, postFromStore, postId]);
const {formatMessage} = useIntl();
if (!post || !channel || !team) {
@ -56,6 +95,7 @@ export default function PostPreviewPropertyRenderer({value}: Props) {
handleFileDropdownOpened={noop}
preventClickAction={true}
previewFooterMessage={postPreviewFooterMessage}
usePostAsSource={true}
/>
</div>
);

View file

@ -8,6 +8,12 @@ import type {
PropertyValue,
} from '@mattermost/types/properties';
import type {
FieldMetadata,
PostPreviewFieldMetadata,
TextFieldMetadata,
} from 'components/properties_card_view/properties_card_view';
import ChannelPropertyRenderer from './channel_property_renderer/channel_property_renderer';
import PostPreviewPropertyRenderer from './post_preview_property_renderer/post_preview_property_renderer';
import SelectPropertyRenderer from './select_property_renderer/selectPropertyRenderer';
@ -21,15 +27,17 @@ import './property_value_renderer.scss';
type Props = {
field: PropertyField;
value: PropertyValue<unknown>;
metadata?: FieldMetadata;
};
export default function PropertyValueRenderer({field, value}: Props) {
export default function PropertyValueRenderer({field, value, metadata}: Props) {
switch (field.type) {
case 'text':
return (
<RenderTextSubtype
field={field}
value={value}
metadata={metadata}
/>
);
case 'user':
@ -51,7 +59,7 @@ export default function PropertyValueRenderer({field, value}: Props) {
}
}
function RenderTextSubtype({field, value}: Props) {
function RenderTextSubtype({field, value, metadata}: Props) {
if (field.type !== 'text') {
return null;
}
@ -59,9 +67,19 @@ function RenderTextSubtype({field, value}: Props) {
const subType = field.attrs?.subType ?? 'text';
switch (subType) {
case 'text':
return <TextPropertyRenderer value={value}/>;
return (
<TextPropertyRenderer
value={value}
metadata={metadata as TextFieldMetadata}
/>
);
case 'post':
return <PostPreviewPropertyRenderer value={value}/>;
return (
<PostPreviewPropertyRenderer
value={value}
metadata={metadata as PostPreviewFieldMetadata}
/>
);
case 'channel':
return <ChannelPropertyRenderer value={value}/>;
case 'team':

View file

@ -36,7 +36,7 @@ function getOptionColors(colorName: string): {backgroundColor: string; color: st
switch (colorName) {
case 'light_blue':
return {
backgroundColor: 'rgba(var(--button-bg-rgb), 0.08)',
backgroundColor: 'var(--sidebar-text-active-border)',
color: '#FFF',
};
case 'dark_blue':
@ -49,8 +49,12 @@ function getOptionColors(colorName: string): {backgroundColor: string; color: st
backgroundColor: 'var(--error-text)',
color: '#FFF',
};
case 'light_grey':
return {
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',
};
default:
// Default is light grey color
return {
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
color: 'rgba(var(--center-channel-color-rgb), 1)',

View file

@ -5,17 +5,28 @@ import React from 'react';
import type {PropertyValue} from '@mattermost/types/properties';
import type {TextFieldMetadata} from 'components/properties_card_view/properties_card_view';
type Props = {
value: PropertyValue<unknown>;
metadata?: TextFieldMetadata;
}
export default function TextPropertyRenderer({value}: Props) {
export default function TextPropertyRenderer({value, metadata}: Props) {
return (
<span
className='TextProperty'
data-testid='text-property'
>
{value.value}
{Boolean(value.value) && value.value}
{
!value.value && metadata?.placeholder && (
<span className='TextProperty__placeholder'>
{metadata.placeholder}
</span>
)
}
</span>
);
}

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
#KeepRemoveFlaggedMessageConfirmationModal {
.modal-content {
width: 704px;
}
.body {
display: flex;
flex-direction: column;
gap: 24px;
.section{
&.comment_section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section_title {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
}
button#PreviewInputTextButton {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
}
textarea#RemoveFlaggedMessageConfirmationModal__comment {
min-height: 90px !important;
max-height: 400px;
}
.request_error {
display: flex;
width: 90%;
align-items: center;
color: var(--error-text);
font-size: 12px;
}
}
}

View file

@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {ServerError} from '@mattermost/types/errors';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import AtMention from 'components/at_mention';
import {useChannel} from 'components/common/hooks/useChannel';
import {useContentFlaggingConfig} from 'components/common/hooks/useContentFlaggingFields';
import {useUser} from 'components/common/hooks/useUser';
import type {TextboxElement} from 'components/textbox';
import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox';
import './remove_flagged_message_confirmation_modal.scss';
const noop = () => {};
type Props = {
action: 'keep' | 'remove';
onExited: () => void;
flaggedPost: Post;
reportingUser: UserProfile;
}
export default function KeepRemoveFlaggedMessageConfirmationModal({action, onExited, flaggedPost, reportingUser}: Props) {
const {formatMessage} = useIntl();
const flaggedPostAuthor = useUser(flaggedPost.user_id);
const flaggedPostChannel = useChannel(flaggedPost.channel_id);
const contentFlaggingConfig = useContentFlaggingConfig(flaggedPostChannel?.team_id || '');
const [comment, setComment] = React.useState<string>('');
const [commentError, setCommentError] = React.useState<string>('');
const [requestError, setRequestError] = React.useState<string>('');
const [submitting, setSubmitting] = React.useState<boolean>(false);
const [showCommentPreview, setShowCommentPreview] = React.useState<boolean>(false);
const handleCommentChange = useCallback((e: React.ChangeEvent<TextboxElement>) => {
setComment(e.target.value);
if (contentFlaggingConfig?.reviewer_comment_required && e.target.value.trim() === '') {
setCommentError(formatMessage({id: 'keep_remove_flag_content_modal.comment_required.error', defaultMessage: 'Please add a comment.'}));
} else {
setCommentError('');
}
}, [contentFlaggingConfig?.reviewer_comment_required, formatMessage]);
const handleToggleCommentPreview = useCallback(() => {
setShowCommentPreview((prev) => !prev);
}, []);
const removeActionLabel = formatMessage({id: 'keep_remove_flag_content_modal.action_remove.title', defaultMessage: 'Remove message from channel'});
const keepActionLabel = formatMessage({id: 'keep_remove_flag_content_modal.action_keep.title', defaultMessage: 'Keep message'});
const removeActionBody = formatMessage({
id: 'keep_remove_flag_content_modal.action_remove.body',
defaultMessage: 'You are about to remove a message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.',
}, {
br: <br/>,
flaggedPostChannel: flaggedPostChannel?.display_name,
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
});
const keepActionBody = formatMessage({
id: 'keep_remove_flag_content_modal.action_keep.body',
defaultMessage: 'You are about to keep a flagged message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.',
}, {
br: <br/>,
flaggedPostChannel: flaggedPostChannel?.display_name,
reportingUser: <AtMention mentionName={reportingUser?.username || ''}/>,
flaggedPostAuthor: <AtMention mentionName={flaggedPostAuthor?.username || ''}/>,
});
const removeActionBodySubTextReporterNotification = formatMessage({
id: 'keep_remove_flag_content_modal.action_remove.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel and a notification will be sent to the reporter of the flag. This action cannot be reverted.',
});
const removeActionBodySubTextNoReporterNotification = formatMessage({
id: 'keep_remove_flag_content_modal.action_remove.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be removed from the channel. This action cannot be reverted.',
});
const keepActionBodySubTextReporterNotification = formatMessage({
id: 'keep_remove_flag_content_modal.action_keep.subtext.notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter of the flag.',
});
const keepActionBodySubTextNoReporterNotification = formatMessage({
id: 'keep_remove_flag_content_modal.action_keep.subtext.no_notify_reporter',
defaultMessage: 'If you confirm, the message will be visible to all channel members.',
});
const requiredCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.required_comment.title', defaultMessage: 'Comment (required)'});
const optionalCommentSectionTitle = formatMessage({id: 'remove_flag_post_confirm_modal.optional_comment.title', defaultMessage: 'Comment (optional)'});
const commentPlaceholder = formatMessage({id: 'keep_remove_flag_content_modal.comment.placeholder', defaultMessage: 'Add your comment here'});
const removeMessageButtonText = formatMessage({id: 'keep_remove_flag_content_modal.action_remove.button_text', defaultMessage: 'Remove message'});
const keepMessageButtonText = formatMessage({id: 'keep_remove_flag_content_modal.action_keep.button_text', defaultMessage: 'Keep message'});
let label;
let subtext;
let body;
let buttonText;
let confirmButtonClass;
if (action === 'remove') {
label = removeActionLabel;
body = removeActionBody;
buttonText = removeMessageButtonText;
confirmButtonClass = 'btn-danger';
if (contentFlaggingConfig?.notify_reporter_on_removal) {
subtext = removeActionBodySubTextReporterNotification;
} else {
subtext = removeActionBodySubTextNoReporterNotification;
}
} else {
label = keepActionLabel;
body = keepActionBody;
buttonText = keepMessageButtonText;
confirmButtonClass = 'btn-primary';
if (contentFlaggingConfig?.notify_reporter_on_dismissal) {
subtext = keepActionBodySubTextReporterNotification;
} else {
subtext = keepActionBodySubTextNoReporterNotification;
}
}
const validateForm = useCallback(() => {
let hasErrors = false;
if (contentFlaggingConfig?.reviewer_comment_required && comment.trim() === '') {
setCommentError(formatMessage({id: 'keep_remove_flag_content_modal.comment_required.error', defaultMessage: 'Please add a comment.'}));
hasErrors = true;
} else {
setCommentError('');
}
return hasErrors;
}, [comment, contentFlaggingConfig?.reviewer_comment_required, formatMessage]);
const handleConfirm = useCallback(async () => {
const hasError = validateForm();
if (hasError) {
return;
}
const actionFunc = action === 'remove' ? Client4.removeFlaggedPost : Client4.keepFlaggedPost;
try {
setSubmitting(true);
await actionFunc(flaggedPost.id, comment);
onExited();
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
setRequestError((error as ServerError).message);
} finally {
setSubmitting(false);
}
}, [action, comment, flaggedPost.id, onExited, validateForm]);
return (
<GenericModal
id='KeepRemoveFlaggedMessageConfirmationModal'
dataTestId='keep-remove-flagged-message-confirmation-modal'
ariaLabel={label}
modalHeaderText={label}
compassDesign={true}
keyboardEscape={true}
enforceFocus={false}
handleConfirm={handleConfirm}
handleCancel={noop}
onExited={onExited}
confirmButtonText={buttonText}
confirmButtonClassName={confirmButtonClass}
autoCloseOnConfirmButton={false}
isConfirmDisabled={submitting}
>
<div className='body'>
<div className='section'>
{body}
<br/>
<br/>
{subtext}
</div>
<div className='section comment_section'>
<div
className='section_title'
>
{contentFlaggingConfig?.reviewer_comment_required ? requiredCommentSectionTitle : optionalCommentSectionTitle}
</div>
<AdvancedTextbox
id='RemoveFlaggedMessageConfirmationModal__comment'
channelId={flaggedPost.channel_id}
value={comment}
onChange={handleCommentChange}
createMessage={commentPlaceholder}
preview={showCommentPreview}
togglePreview={handleToggleCommentPreview}
useChannelMentions={false}
onKeyPress={() => {}}
hasError={false}
errorMessage={commentError}
maxLength={1000}
/>
</div>
{requestError &&
<div className='request_error'>
<i className='icon icon-alert-outline'/>
<span>{requestError}</span>
</div>
}
</div>
</GenericModal>
);
}

View file

@ -8,6 +8,10 @@ import Textbox, {type Props} from 'components/textbox/textbox';
import {TestHelper} from 'utils/test_helper';
jest.mock('components/remove_flagged_message_confirmation_modal/remove_flagged_message_confirmation_modal', () => {
return jest.fn(() => <div data-testid='keep-remove-flagged-message-confirmation-modal'>{'KeepRemoveFlaggedMessageConfirmationModal Mock'}</div>);
});
describe('components/TextBox', () => {
const baseProps: Props = {
channelId: 'channelId',

View file

@ -4009,9 +4009,11 @@
"custom_status.suggestions.recent_title": "RECENT",
"custom_status.suggestions.title": "SUGGESTIONS",
"custom_status.suggestions.working_from_home": "Working from home",
"data_spillage_report_post.reporting_comment.placeholder": "No comment.",
"data_spillage_report_post.title": "{user} flagged a message for review",
"data_spillage_report.keep_message.button_text": "Keep message",
"data_spillage_report.remove_message.button_text": "Remove message",
"data_spillage_report.view_details.button_text": "View details",
"date_separator.today": "Today",
"date_separator.tomorrow": "Tomorrow",
"date_separator.yesterday": "Yesterday",
@ -4655,6 +4657,18 @@
"joinChannel.JoinButton": "Join",
"joinChannel.joiningButton": "Joining...",
"katex.error": "Couldn't compile your Latex code. Please review the syntax and try again.",
"keep_remove_flag_content_modal.action_keep.body": "You are about to keep a flagged message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.",
"keep_remove_flag_content_modal.action_keep.button_text": "Keep message",
"keep_remove_flag_content_modal.action_keep.subtext.no_notify_reporter": "If you confirm, the message will be visible to all channel members.",
"keep_remove_flag_content_modal.action_keep.subtext.notify_reporter": "If you confirm, the message will be visible to all channel members and a notification will be sent to the reporter of the flag.",
"keep_remove_flag_content_modal.action_keep.title": "Keep message",
"keep_remove_flag_content_modal.action_remove.body": "You are about to remove a message authored by {flaggedPostAuthor} posed in the {flaggedPostChannel} channel and flagged for review by {reportingUser}.",
"keep_remove_flag_content_modal.action_remove.button_text": "Remove message",
"keep_remove_flag_content_modal.action_remove.subtext.no_notify_reporter": "If you confirm, the message will be removed from the channel. This action cannot be reverted.",
"keep_remove_flag_content_modal.action_remove.subtext.notify_reporter": "If you confirm, the message will be removed from the channel and a notification will be sent to the reporter of the flag. This action cannot be reverted.",
"keep_remove_flag_content_modal.action_remove.title": "Remove message from channel",
"keep_remove_flag_content_modal.comment_required.error": "Please add a comment.",
"keep_remove_flag_content_modal.comment.placeholder": "Add your comment here",
"last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.",
"last_users_message.added_to_team.type": "were **added to the team** by {actor}.",
"last_users_message.first": "{firstUser} and ",
@ -5238,6 +5252,21 @@
"promote_to_user_modal.promote": "Promote",
"promote_to_user_modal.title": "Promote guest {username} to member",
"property_card.actions_row.label": "Actions",
"property_card.field.action_time.label": "Reviewed at",
"property_card.field.actor_comment.label": "Reviewer's comment",
"property_card.field.actor_user_id.label": "Reviewed by",
"property_card.field.channel.label": "Channel",
"property_card.field.post_author.label": "Posted by",
"property_card.field.post_creation_time.label": "Posted at",
"property_card.field.post_id.label": "Post ID",
"property_card.field.post_preview.label": "Message",
"property_card.field.reporting_comment.label": "Comment",
"property_card.field.reporting_reason.label": "Reason",
"property_card.field.reporting_time.label": "Flagged at",
"property_card.field.reporting_user_id.label": "Flagged by",
"property_card.field.reviewer_user_id.label": "Reviewer",
"property_card.field.status.label": "Status",
"property_card.field.team.label": "Team",
"public_private_selector.private.description": "Only invited members",
"public_private_selector.private.title": "Private",
"public_private_selector.public.description": "Anyone",
@ -5264,6 +5293,8 @@
"reaction.usersAndOthersReacted": "{users} and {otherUsers, number} other {otherUsers, plural, one {user} other {users}}",
"reaction.usersReacted": "{users} and {lastUser}",
"reaction.you": "You",
"remove_flag_post_confirm_modal.optional_comment.title": "Comment (optional)",
"remove_flag_post_confirm_modal.required_comment.title": "Comment (required)",
"remove_group_confirm_button": "Yes, Remove Group and {memberCount, plural, one {Member} other {Members}}",
"remove_group_confirm_message": "{memberCount, number} {memberCount, plural, one {member} other {members}} associated to this group will be removed from the team. Are you sure you wish to remove this group and {memberCount} {memberCount, plural, one {member} other {members}}?",
"remove_group_confirm_title": "Remove Group and {memberCount, number} {memberCount, plural, one {Member} other {Members}}",

View file

@ -5,4 +5,7 @@ import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_CONTENT_FLAGGING_CONFIG: null,
RECEIVED_POST_CONTENT_FLAGGING_FIELDS: null,
RECEIVED_POST_CONTENT_FLAGGING_VALUES: null,
CONTENT_FLAGGING_REPORT_VALUE_UPDATED: null,
});

View file

@ -2,12 +2,14 @@
// See LICENSE.txt for license information.
import type {ContentFlaggingConfig} from '@mattermost/types/content_flagging';
import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties';
import {TeamTypes, ContentFlaggingTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader';
export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{enabled: boolean}> {
return async (dispatch, getState) => {
@ -33,12 +35,12 @@ export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{e
};
}
export function getContentFlaggingConfig(): ActionFuncAsync<ContentFlaggingConfig> {
export function getContentFlaggingConfig(teamId?: string): ActionFuncAsync<ContentFlaggingConfig> {
return async (dispatch, getState) => {
let response;
try {
response = await Client4.getContentFlaggingConfig();
response = await Client4.getContentFlaggingConfig(teamId);
dispatch({
type: ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CONFIG,
@ -53,3 +55,65 @@ export function getContentFlaggingConfig(): ActionFuncAsync<ContentFlaggingConfi
return {data: response};
};
}
export function getPostContentFlaggingFields(): ActionFuncAsync<NameMappedPropertyFields> {
return async (dispatch, getState) => {
let data;
try {
data = await Client4.getPostContentFlaggingFields();
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
dispatch({
type: ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_FIELDS,
data,
});
return {data};
};
}
export function loadPostContentFlaggingFields(): ActionFuncAsync<NameMappedPropertyFields> {
// Use data loader and fetch data to manage multiple, simultaneous dispatches
return async (dispatch, getState, {loaders}: any) => {
if (!loaders.postContentFlaggingFieldsLoader) {
loaders.postContentFlaggingFieldsLoader = new DelayedDataLoader<NameMappedPropertyFields>({
fetchBatch: () => dispatch(getPostContentFlaggingFields()),
maxBatchSize: 1,
wait: 200,
});
}
const loader = loaders.postContentFlaggingFieldsLoader;
loader.queue([true]);
return {};
};
}
export function getPostContentFlaggingValues(postId: string): ActionFuncAsync<Array<PropertyValue<unknown>>> {
return async (dispatch, getState) => {
let response;
try {
response = await Client4.getPostContentFlaggingValues(postId);
dispatch({
type: ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_VALUES,
data: {
postId,
values: response,
},
});
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
return {data: response};
};
}

View file

@ -150,13 +150,13 @@ export function postPinnedChanged(postId: string, isPinned: boolean, updateAt =
};
}
export function getPost(postId: string): ActionFuncAsync<Post> {
export function getPost(postId: string, includeDeleted?: boolean, retainContent?: boolean): ActionFuncAsync<Post> {
return async (dispatch, getState) => {
let post;
const crtEnabled = isCollapsedThreadsEnabled(getState());
try {
post = await Client4.getPost(postId);
post = await Client4.getPost(postId, includeDeleted, retainContent);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: PostTypes.GET_POSTS_FAILURE, error});

View file

@ -3,10 +3,19 @@
import {combineReducers} from 'redux';
import type {
ContentFlaggingConfig,
ContentFlaggingState,
} from '@mattermost/types/content_flagging';
import type {
NameMappedPropertyFields,
PropertyValue,
} from '@mattermost/types/properties';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {ContentFlaggingTypes} from 'mattermost-redux/action_types';
function settings(state = {}, action: MMReduxAction) {
function settings(state: ContentFlaggingState['settings'] = {} as ContentFlaggingConfig, action: MMReduxAction) {
switch (action.type) {
case ContentFlaggingTypes.RECEIVED_CONTENT_FLAGGING_CONFIG: {
return {
@ -19,6 +28,52 @@ function settings(state = {}, action: MMReduxAction) {
}
}
function fields(state: ContentFlaggingState['fields'] = {} as NameMappedPropertyFields, action: MMReduxAction) {
switch (action.type) {
case ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_FIELDS: {
return {
...state,
...action.data,
};
}
default:
return state;
}
}
function postValues(state: ContentFlaggingState['postValues'] = {}, action: MMReduxAction) {
switch (action.type) {
case ContentFlaggingTypes.RECEIVED_POST_CONTENT_FLAGGING_VALUES: {
return {
...state,
[action.data.postId]: action.data.values,
};
}
case ContentFlaggingTypes.CONTENT_FLAGGING_REPORT_VALUE_UPDATED: {
const postId = action.data.target_id as string;
const existingPropertyValues = state[postId] || {};
const updatedPropertyValues = JSON.parse(action.data.property_values);
const valuesByFieldId = {} as Record<string, PropertyValue<unknown>>;
existingPropertyValues.forEach((property: PropertyValue<unknown>) => {
valuesByFieldId[property.field_id] = property;
});
updatedPropertyValues.forEach((property: PropertyValue<unknown>) => {
valuesByFieldId[property.field_id] = property;
});
return {
...state,
[postId]: Object.values(valuesByFieldId),
};
}
default:
return state;
}
}
export default combineReducers({
settings,
fields,
postValues,
});

View file

@ -257,7 +257,7 @@ function profiles(state: UsersState['profiles'] = {}, action: MMReduxAction) {
}, {});
}
case UserTypes.RECEIVED_PROFILES_LIST: {
const users: UserProfile[] = action.data;
const users: UserProfile[] = action.data || [] as UserProfile[];
return users.reduce(receiveUserProfile, state);
}

View file

@ -12,4 +12,17 @@ export const contentFlaggingFeatureEnabled = (state: GlobalState): boolean => {
return featureFlagEnabled && featureEnabled;
};
export const contentFlaggingConfig = (state: GlobalState) => state.entities.contentFlagging.settings;
export const contentFlaggingConfig = (state: GlobalState) => {
const config = state.entities.contentFlagging.settings;
return (config && Object.keys(config).length) ? config : undefined;
};
export const contentFlaggingFields = (state: GlobalState) => {
const fields = state.entities.contentFlagging.fields;
return (fields && Object.keys(fields).length) ? fields : undefined;
};
export const postContentFlaggingValues = (state: GlobalState, postId: string) => {
const values = state.entities.contentFlagging.postValues || {};
return values[postId];
};

View file

@ -484,6 +484,7 @@ export const ModalIdentifiers = {
ATTRIBUTE_MODAL_LDAP: 'attribute_modal_ldap',
ATTRIBUTE_MODAL_SAML: 'attribute_modal_saml',
FLAG_POST: 'flag_post',
REMOVE_FLAGGED_POST: 'remove_flagged_post',
};
export const UserStatuses = {
@ -701,6 +702,7 @@ export const SocketEvents = {
CPA_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
CONTENT_FLAGGING_REPORT_VALUE_CHANGED: 'content_flagging_report_value_updated',
};
export const TutorialSteps = {
@ -1482,20 +1484,8 @@ export const ZoomSettings = {
};
export const DataSpillagePropertyNames = {
Status: 'Status',
FlaggedBy: 'Flagged by',
Reason: 'Reason',
Comment: 'Comment',
ReportingTime: 'Reporting Time',
ReviewingUser: 'Reviewing User',
ActionBy: 'Action By',
ActionComment: 'Action Comment',
ActionTime: 'Action Time',
Message: 'Message',
PostedIn: 'Posted in',
Team: 'Team',
PostedBy: 'Posted by',
PostedAt: 'Posted at',
FlaggedBy: 'reporting_user_id',
Status: 'status',
};
export const Constants = {

View file

@ -112,7 +112,12 @@ import type {
import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {ProductNotices} from '@mattermost/types/product_notices';
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
import type {
NameMappedPropertyFields,
UserPropertyField,
UserPropertyFieldPatch,
PropertyValue,
} from '@mattermost/types/properties';
import type {Reaction} from '@mattermost/types/reactions';
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
@ -2206,9 +2211,9 @@ export default class Client4 {
);
};
getPost = (postId: string) => {
getPost = (postId: string, includeDeleted?: boolean, retainContent?: boolean) => {
return this.doFetch<Post>(
`${this.getPostRoute(postId)}`,
`${this.getPostRoute(postId)}${buildQueryString({include_deleted: includeDeleted, retain_content: retainContent})}`,
{method: 'get'},
);
};
@ -4621,9 +4626,60 @@ export default class Client4 {
);
};
getContentFlaggingConfig = () => {
getContentFlaggingConfig = (teamId?: string) => {
return this.doFetch<ContentFlaggingConfig>(
`${this.getContentFlaggingRoute()}/flag/config`,
`${this.getContentFlaggingRoute()}/flag/config${buildQueryString({team_id: teamId})}`,
{method: 'get'},
);
};
flagPost = (postId: string, reason: string, comment?: string) => {
return this.doFetch<StatusOK>(
`${this.getContentFlaggingRoute()}/post/${postId}/flag`,
{
method: 'post',
body: JSON.stringify({reason, comment: JSON.stringify(comment)}),
},
);
};
removeFlaggedPost = (postId: string, comment?: string) => {
return this.doFetch<StatusOK>(
`${this.getContentFlaggingRoute()}/post/${postId}/remove`,
{
method: 'put',
body: JSON.stringify({comment: JSON.stringify(comment)}),
},
);
};
keepFlaggedPost = (postId: string, comment?: string) => {
return this.doFetch<StatusOK>(
`${this.getContentFlaggingRoute()}/post/${postId}/keep`,
{
method: 'put',
body: JSON.stringify({comment: JSON.stringify(comment)}),
},
);
};
getPostContentFlaggingFields = () => {
return this.doFetch<NameMappedPropertyFields>(
`${this.getContentFlaggingRoute()}/fields`,
{method: 'get'},
);
};
getPostContentFlaggingValues = (postId: string) => {
return this.doFetch<Array<PropertyValue<unknown>>>(
`${this.getContentFlaggingRoute()}/post/${postId}/field_values`,
{method: 'get'},
);
};
getFlaggedPost = (postId: string) => {
return this.doFetch<Post>(
`${this.getContentFlaggingRoute()}/post/${postId}`,
{method: 'get'},
);
};

View file

@ -1,6 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Post} from './posts';
import type {
NameMappedPropertyFields,
PropertyValue,
} from './properties';
export type ContentFlaggingEvent = 'flagged' | 'assigned' | 'removed' | 'dismissed';
export type NotificationTarget = 'reviewers' | 'author' | 'reporter';
@ -8,4 +14,22 @@ export type NotificationTarget = 'reviewers' | 'author' | 'reporter';
export type ContentFlaggingConfig = {
reasons: string[];
reporter_comment_required: boolean;
reviewer_comment_required: boolean;
notify_reporter_on_dismissal?: boolean;
notify_reporter_on_removal?: boolean;
};
export type ContentFlaggingState = {
settings?: ContentFlaggingConfig;
fields?: NameMappedPropertyFields;
postValues?: {
[key: Post['id']]: Array<PropertyValue<unknown>>;
};
};
export enum ContentFlaggingStatus {
Pending = 'Pending',
Assigned = 'Assigned',
Removed = 'Removed',
Retained = 'Retained',
}

View file

@ -26,6 +26,8 @@ export type PropertyField = {
delete_at: number;
};
export type NameMappedPropertyFields = {[key: PropertyField['name']]: PropertyField};
export type PropertyValue<T> = {
id: string;
target_id: string;

View file

@ -8,7 +8,7 @@ import type {ChannelBookmarksState} from './channel_bookmarks';
import type {ChannelCategoriesState} from './channel_categories';
import type {ChannelsState} from './channels';
import type {CloudState, CloudUsage} from './cloud';
import type {ContentFlaggingConfig} from './content_flagging';
import type {ContentFlaggingState} from './content_flagging';
import type {EmojisState} from './emojis';
import type {FilesState} from './files';
import type {GeneralState} from './general';
@ -83,9 +83,7 @@ export type GlobalState = {
remotes?: Record<string, RemoteClusterInfo[]>;
remotesByRemoteId?: Record<string, RemoteClusterInfo>;
};
contentFlagging: {
settings?: ContentFlaggingConfig;
};
contentFlagging: ContentFlaggingState;
};
errors: any[];
requests: {