mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-25 13:16:40 +00:00
[feature/frontend] Reports frontend v2 (#3022)
* use apiutil + paging in admin processor+handlers * we're making it happen * fix little whoopsie * styling for report list * don't youuuu forget about meee don't don't don't don't * last bits * sanitize content before showing in report statuses * update report docs
This commit is contained in:
parent
b08c1bd0cb
commit
d2b3d37724
|
@ -20,12 +20,14 @@ Instance moderation settings.
|
||||||
|
|
||||||
### Reports
|
### Reports
|
||||||
|
|
||||||
![List of reports for testing, one resolved and one open.](../assets/admin-settings-reports.png)
|
![List of reports for testing, showing one open report.](../assets/admin-settings-reports.png)
|
||||||
|
|
||||||
The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username).
|
The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username).
|
||||||
|
|
||||||
Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. You can also use this view to mark a report as resolved, and fill in a comment. Whatever comment you enter here will be visible to the user that created the report, if that user is from your instance.
|
Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. You can also use this view to mark a report as resolved, and fill in a comment. Whatever comment you enter here will be visible to the user that created the report, if that user is from your instance.
|
||||||
|
|
||||||
|
![The detailed view of an open report, showing the reported status and the reason for the report.](../assets/admin-settings-report-detail.png)
|
||||||
|
|
||||||
Clicking on the username of the reported account opens that account in the 'Accounts' view, allowing you to perform moderation actions on it.
|
Clicking on the username of the reported account opens that account in the 'Accounts' view, allowing you to perform moderation actions on it.
|
||||||
|
|
||||||
### Accounts
|
### Accounts
|
||||||
|
|
|
@ -4525,6 +4525,8 @@ paths:
|
||||||
- default: 50
|
- default: 50
|
||||||
description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
||||||
in: query
|
in: query
|
||||||
|
maximum: 200
|
||||||
|
minimum: 0
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
- description: |-
|
- description: |-
|
||||||
|
@ -5739,21 +5741,23 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
name: target_account_id
|
name: target_account_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *OLDER* than the given max ID. The report with the specified ID will not be included in the response.
|
- description: Return only reports *OLDER* than the given max ID (for paging downwards). The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to min_id.
|
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: since_id
|
name: since_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *NEWER* than the given min ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to since_id.
|
- description: Return only reports immediately *NEWER* than the given min ID (for paging upwards). The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
type: string
|
||||||
- default: 20
|
- default: 20
|
||||||
description: Number of reports to return. If more than 100 or less than 1, will be clamped to 100.
|
description: Number of reports to return.
|
||||||
in: query
|
in: query
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
produces:
|
produces:
|
||||||
|
@ -7707,21 +7711,23 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
name: target_account_id
|
name: target_account_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *OLDER* than the given max ID. The report with the specified ID will not be included in the response.
|
- description: Return only reports *OLDER* than the given max ID (for paging downwards). The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to min_id.
|
- description: Return only reports *NEWER* than the given since ID. The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: since_id
|
name: since_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only reports *NEWER* than the given min ID. The report with the specified ID will not be included in the response. This parameter is functionally equivalent to since_id.
|
- description: Return only reports immediately *NEWER* than the given min ID (for paging upwards). The report with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
type: string
|
||||||
- default: 20
|
- default: 20
|
||||||
description: Number of reports to return. If less than 1, will be clamped to 1. If more than 100, will be clamped to 100.
|
description: Number of reports to return.
|
||||||
in: query
|
in: query
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
name: limit
|
name: limit
|
||||||
type: integer
|
type: integer
|
||||||
produces:
|
produces:
|
||||||
|
|
BIN
docs/assets/admin-settings-report-detail.png
Normal file
BIN
docs/assets/admin-settings-report-detail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 109 KiB |
|
@ -116,10 +116,9 @@ func (m *Module) AccountActionPOSTHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetAcctID := c.Param(IDKey)
|
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if targetAcctID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no account id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.TargetID = targetAcctID
|
form.TargetID = targetAcctID
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
|
|
||||||
"codeberg.org/gruf/go-debug"
|
"codeberg.org/gruf/go-debug"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
)
|
)
|
||||||
|
@ -29,48 +30,40 @@
|
||||||
const (
|
const (
|
||||||
BasePath = "/v1/admin"
|
BasePath = "/v1/admin"
|
||||||
EmojiPath = BasePath + "/custom_emojis"
|
EmojiPath = BasePath + "/custom_emojis"
|
||||||
EmojiPathWithID = EmojiPath + "/:" + IDKey
|
EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
|
||||||
EmojiCategoriesPath = EmojiPath + "/categories"
|
EmojiCategoriesPath = EmojiPath + "/categories"
|
||||||
DomainBlocksPath = BasePath + "/domain_blocks"
|
DomainBlocksPath = BasePath + "/domain_blocks"
|
||||||
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
|
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
|
||||||
DomainAllowsPath = BasePath + "/domain_allows"
|
DomainAllowsPath = BasePath + "/domain_allows"
|
||||||
DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey
|
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
|
||||||
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
|
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
|
||||||
HeaderAllowsPath = BasePath + "/header_allows"
|
HeaderAllowsPath = BasePath + "/header_allows"
|
||||||
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey
|
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
|
||||||
HeaderBlocksPath = BasePath + "/header_blocks"
|
HeaderBlocksPath = BasePath + "/header_blocks"
|
||||||
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey
|
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
|
||||||
AccountsV1Path = BasePath + "/accounts"
|
AccountsV1Path = BasePath + "/accounts"
|
||||||
AccountsV2Path = "/v2/admin/accounts"
|
AccountsV2Path = "/v2/admin/accounts"
|
||||||
AccountsPathWithID = AccountsV1Path + "/:" + IDKey
|
AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
|
||||||
AccountsActionPath = AccountsPathWithID + "/action"
|
AccountsActionPath = AccountsPathWithID + "/action"
|
||||||
AccountsApprovePath = AccountsPathWithID + "/approve"
|
AccountsApprovePath = AccountsPathWithID + "/approve"
|
||||||
AccountsRejectPath = AccountsPathWithID + "/reject"
|
AccountsRejectPath = AccountsPathWithID + "/reject"
|
||||||
MediaCleanupPath = BasePath + "/media_cleanup"
|
MediaCleanupPath = BasePath + "/media_cleanup"
|
||||||
MediaRefetchPath = BasePath + "/media_refetch"
|
MediaRefetchPath = BasePath + "/media_refetch"
|
||||||
ReportsPath = BasePath + "/reports"
|
ReportsPath = BasePath + "/reports"
|
||||||
ReportsPathWithID = ReportsPath + "/:" + IDKey
|
ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
|
||||||
ReportsResolvePath = ReportsPathWithID + "/resolve"
|
ReportsResolvePath = ReportsPathWithID + "/resolve"
|
||||||
EmailPath = BasePath + "/email"
|
EmailPath = BasePath + "/email"
|
||||||
EmailTestPath = EmailPath + "/test"
|
EmailTestPath = EmailPath + "/test"
|
||||||
InstanceRulesPath = BasePath + "/instance/rules"
|
InstanceRulesPath = BasePath + "/instance/rules"
|
||||||
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey
|
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
|
||||||
DebugPath = BasePath + "/debug"
|
DebugPath = BasePath + "/debug"
|
||||||
DebugAPUrlPath = DebugPath + "/apurl"
|
DebugAPUrlPath = DebugPath + "/apurl"
|
||||||
DebugClearCachesPath = DebugPath + "/caches/clear"
|
DebugClearCachesPath = DebugPath + "/caches/clear"
|
||||||
|
|
||||||
IDKey = "id"
|
|
||||||
FilterQueryKey = "filter"
|
FilterQueryKey = "filter"
|
||||||
MaxShortcodeDomainKey = "max_shortcode_domain"
|
MaxShortcodeDomainKey = "max_shortcode_domain"
|
||||||
MinShortcodeDomainKey = "min_shortcode_domain"
|
MinShortcodeDomainKey = "min_shortcode_domain"
|
||||||
LimitKey = "limit"
|
|
||||||
DomainQueryKey = "domain"
|
DomainQueryKey = "domain"
|
||||||
ResolvedKey = "resolved"
|
|
||||||
AccountIDKey = "account_id"
|
|
||||||
TargetAccountIDKey = "target_account_id"
|
|
||||||
MaxIDKey = "max_id"
|
|
||||||
SinceIDKey = "since_id"
|
|
||||||
MinIDKey = "min_id"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Module struct {
|
type Module struct {
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -97,10 +96,9 @@ func (m *Module) EmojiDELETEHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiID := c.Param(IDKey)
|
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if emojiID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no emoji id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -41,7 +42,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
suite.adminModule.EmojiDELETEHandler(ctx)
|
suite.adminModule.EmojiDELETEHandler(ctx)
|
||||||
suite.Equal(http.StatusOK, recorder.Code)
|
suite.Equal(http.StatusOK, recorder.Code)
|
||||||
|
@ -78,7 +79,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete2() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
suite.adminModule.EmojiDELETEHandler(ctx)
|
suite.adminModule.EmojiDELETEHandler(ctx)
|
||||||
suite.Equal(http.StatusBadRequest, recorder.Code)
|
suite.Equal(http.StatusBadRequest, recorder.Code)
|
||||||
|
@ -100,7 +101,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDeleteNotFound() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodDelete, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
|
ctx.AddParam(apiutil.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
|
||||||
|
|
||||||
suite.adminModule.EmojiDELETEHandler(ctx)
|
suite.adminModule.EmojiDELETEHandler(ctx)
|
||||||
suite.Equal(http.StatusNotFound, recorder.Code)
|
suite.Equal(http.StatusNotFound, recorder.Code)
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -82,10 +81,9 @@ func (m *Module) EmojiGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiID := c.Param(IDKey)
|
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if emojiID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no emoji id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmojiGetTestSuite struct {
|
type EmojiGetTestSuite struct {
|
||||||
|
@ -39,7 +40,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
suite.adminModule.EmojiGETHandler(ctx)
|
suite.adminModule.EmojiGETHandler(ctx)
|
||||||
suite.Equal(http.StatusOK, recorder.Code)
|
suite.Equal(http.StatusOK, recorder.Code)
|
||||||
|
@ -71,7 +72,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet2() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
suite.adminModule.EmojiGETHandler(ctx)
|
suite.adminModule.EmojiGETHandler(ctx)
|
||||||
suite.Equal(http.StatusOK, recorder.Code)
|
suite.Equal(http.StatusOK, recorder.Code)
|
||||||
|
@ -102,7 +103,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGetNotFound() {
|
||||||
|
|
||||||
path := admin.EmojiPathWithID
|
path := admin.EmojiPathWithID
|
||||||
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
ctx := suite.newContext(recorder, http.MethodGet, nil, path, "application/json")
|
||||||
ctx.AddParam(admin.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
|
ctx.AddParam(apiutil.IDKey, "01GF8VRXX1R00X7XH8973Z29R1")
|
||||||
|
|
||||||
suite.adminModule.EmojiGETHandler(ctx)
|
suite.adminModule.EmojiGETHandler(ctx)
|
||||||
suite.Equal(http.StatusNotFound, recorder.Code)
|
suite.Equal(http.StatusNotFound, recorder.Code)
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
@ -76,6 +75,8 @@
|
||||||
// type: integer
|
// type: integer
|
||||||
// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
|
||||||
// default: 50
|
// default: 50
|
||||||
|
// minimum: 0
|
||||||
|
// maximum: 200
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: max_shortcode_domain
|
// name: max_shortcode_domain
|
||||||
|
@ -142,20 +143,11 @@ func (m *Module) EmojisGETHandler(c *gin.Context) {
|
||||||
maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
|
maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
|
||||||
minShortcodeDomain := c.Query(MinShortcodeDomainKey)
|
minShortcodeDomain := c.Query(MinShortcodeDomainKey)
|
||||||
|
|
||||||
limit := 50
|
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 50, 200, 0)
|
||||||
limitString := c.Query(LimitKey)
|
if errWithCode != nil {
|
||||||
if limitString != "" {
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
i, err := strconv.ParseInt(limitString, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit = int(i)
|
|
||||||
}
|
|
||||||
if limit < 0 {
|
|
||||||
limit = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var domain string
|
var domain string
|
||||||
var includeDisabled bool
|
var includeDisabled bool
|
||||||
|
|
|
@ -147,10 +147,9 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiID := c.Param(IDKey)
|
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if emojiID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no emoji id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
)
|
)
|
||||||
|
@ -53,7 +54,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -130,7 +131,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -208,7 +209,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -284,7 +285,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -325,7 +326,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -358,7 +359,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -391,7 +392,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -425,7 +426,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -459,7 +460,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -492,7 +493,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
@ -526,7 +527,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() {
|
||||||
bodyBytes := requestBody.Bytes()
|
bodyBytes := requestBody.Bytes()
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
ctx := suite.newContext(recorder, http.MethodPost, bodyBytes, admin.EmojiPathWithID, w.FormDataContentType())
|
||||||
ctx.AddParam(admin.IDKey, testEmoji.ID)
|
ctx.AddParam(apiutil.IDKey, testEmoji.ID)
|
||||||
|
|
||||||
// call the handler
|
// call the handler
|
||||||
suite.adminModule.EmojiPATCHHandler(ctx)
|
suite.adminModule.EmojiPATCHHandler(ctx)
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -85,10 +84,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reportID := c.Param(IDKey)
|
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if reportID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no report id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -107,10 +106,9 @@ func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reportID := c.Param(IDKey)
|
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if reportID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no report id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
@ -65,7 +66,7 @@ func (suite *ReportResolveTestSuite) resolveReport(
|
||||||
|
|
||||||
// create the request
|
// create the request
|
||||||
ctx.Request = httptest.NewRequest(http.MethodPost, requestURI, nil)
|
ctx.Request = httptest.NewRequest(http.MethodPost, requestURI, nil)
|
||||||
ctx.AddParam(admin.IDKey, targetReportID)
|
ctx.AddParam(apiutil.IDKey, targetReportID)
|
||||||
ctx.Request.Header.Set("accept", "application/json")
|
ctx.Request.Header.Set("accept", "application/json")
|
||||||
if actionTakenComment != nil {
|
if actionTakenComment != nil {
|
||||||
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}
|
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}
|
||||||
|
|
|
@ -20,12 +20,12 @@
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
|
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
// name: max_id
|
// name: max_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *OLDER* than the given max ID.
|
// Return only reports *OLDER* than the given max ID (for paging downwards).
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
|
@ -81,23 +81,21 @@
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *NEWER* than the given since ID.
|
// Return only reports *NEWER* than the given since ID.
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// This parameter is functionally equivalent to min_id.
|
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: min_id
|
// name: min_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *NEWER* than the given min ID.
|
// Return only reports immediately *NEWER* than the given min ID (for paging upwards).
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// This parameter is functionally equivalent to since_id.
|
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: limit
|
// name: limit
|
||||||
// type: integer
|
// type: integer
|
||||||
// description: >-
|
// description: Number of reports to return.
|
||||||
// Number of reports to return.
|
|
||||||
// If more than 100 or less than 1, will be clamped to 100.
|
|
||||||
// default: 20
|
// default: 20
|
||||||
|
// minimum: 1
|
||||||
|
// maximum: 100
|
||||||
// in: query
|
// in: query
|
||||||
//
|
//
|
||||||
// security:
|
// security:
|
||||||
|
@ -144,34 +142,30 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolved *bool
|
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
|
||||||
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
|
if errWithCode != nil {
|
||||||
i, err := strconv.ParseBool(resolvedString)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = &i
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 20
|
|
||||||
if limitString := c.Query(LimitKey); limitString != "" {
|
|
||||||
i, err := strconv.Atoi(limitString)
|
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize
|
page, errWithCode := paging.ParseIDPage(c,
|
||||||
if i < 1 || i > 100 {
|
1, // min limit
|
||||||
i = 100
|
100, // max limit
|
||||||
}
|
20, // default limit
|
||||||
limit = i
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, errWithCode := m.processor.Admin().ReportsGet(c.Request.Context(), authed.Account, resolved, c.Query(AccountIDKey), c.Query(TargetAccountIDKey), c.Query(MaxIDKey), c.Query(SinceIDKey), c.Query(MinIDKey), limit)
|
resp, errWithCode := m.processor.Admin().ReportsGet(
|
||||||
|
c.Request.Context(),
|
||||||
|
authed.Account,
|
||||||
|
resolved,
|
||||||
|
c.Query(apiutil.AccountIDKey),
|
||||||
|
c.Query(apiutil.TargetAccountIDKey),
|
||||||
|
page,
|
||||||
|
)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
@ -63,24 +64,24 @@ func (suite *ReportsGetTestSuite) getReports(
|
||||||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||||
|
|
||||||
// create the request URI
|
// create the request URI
|
||||||
requestPath := admin.ReportsPath + "?" + admin.LimitKey + "=" + strconv.Itoa(limit)
|
requestPath := admin.ReportsPath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
requestPath = requestPath + "&" + admin.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||||
}
|
}
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
requestPath = requestPath + "&" + admin.AccountIDKey + "=" + accountID
|
requestPath = requestPath + "&" + apiutil.AccountIDKey + "=" + accountID
|
||||||
}
|
}
|
||||||
if targetAccountID != "" {
|
if targetAccountID != "" {
|
||||||
requestPath = requestPath + "&" + admin.TargetAccountIDKey + "=" + targetAccountID
|
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
|
||||||
}
|
}
|
||||||
if maxID != "" {
|
if maxID != "" {
|
||||||
requestPath = requestPath + "&" + admin.MaxIDKey + "=" + maxID
|
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
|
||||||
}
|
}
|
||||||
if sinceID != "" {
|
if sinceID != "" {
|
||||||
requestPath = requestPath + "&" + admin.SinceIDKey + "=" + sinceID
|
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
|
||||||
}
|
}
|
||||||
if minID != "" {
|
if minID != "" {
|
||||||
requestPath = requestPath + "&" + admin.MinIDKey + "=" + minID
|
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
|
||||||
}
|
}
|
||||||
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
||||||
requestURI := baseURI + "/api/" + requestPath
|
requestURI := baseURI + "/api/" + requestPath
|
||||||
|
@ -766,7 +767,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
|
||||||
}
|
}
|
||||||
]`, string(b))
|
]`, string(b))
|
||||||
|
|
||||||
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R&account_id=01F8MH5NBDF2MV7CTC4Q5128HF>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=20&min_id=01GP3AWY4CRDVRNZKW0TEAMB5R&account_id=01F8MH5NBDF2MV7CTC4Q5128HF>; rel="prev"`, link)
|
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?account_id=01F8MH5NBDF2MV7CTC4Q5128HF&limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?account_id=01F8MH5NBDF2MV7CTC4Q5128HF&limit=20&min_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="prev"`, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
|
func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
|
||||||
|
@ -1028,8 +1029,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetZeroLimit() {
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Len(reports, 2)
|
suite.Len(reports, 2)
|
||||||
|
|
||||||
// Limit in Link header should be set to 100
|
// Limit in Link header should be set to default (20)
|
||||||
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=100&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=100&min_id=01GP3DFY9XQ1TJMZT5BGAZPXX7>; rel="prev"`, link)
|
suite.Equal(`<http://localhost:8080/api/v1/admin/reports?limit=20&max_id=01GP3AWY4CRDVRNZKW0TEAMB5R>; rel="next", <http://localhost:8080/api/v1/admin/reports?limit=20&min_id=01GP3DFY9XQ1TJMZT5BGAZPXX7>; rel="prev"`, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *ReportsGetTestSuite) TestReportsGetHighLimit() {
|
func (suite *ReportsGetTestSuite) TestReportsGetHighLimit() {
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -95,10 +94,9 @@ func (m *Module) RuleDELETEHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleID := c.Param(IDKey)
|
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if ruleID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no rule id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -85,10 +84,9 @@ func (m *Module) RuleGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleID := c.Param(IDKey)
|
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if ruleID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no rule id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
@ -87,10 +86,9 @@ func (m *Module) RulePATCHHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleID := c.Param(IDKey)
|
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if ruleID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no rule id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
package reports
|
package reports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
@ -77,10 +76,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetReportID := c.Param(IDKey)
|
targetReportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
if targetReportID == "" {
|
if errWithCode != nil {
|
||||||
err := errors.New("no report id specified")
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -145,7 +145,7 @@ func (suite *ReportGetTestSuite) TestGetReport2() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *ReportGetTestSuite) TestGetReport3() {
|
func (suite *ReportGetTestSuite) TestGetReport3() {
|
||||||
report, err := suite.getReport(http.StatusBadRequest, `{"error":"Bad Request: no report id specified"}`, "")
|
report, err := suite.getReport(http.StatusBadRequest, `{"error":"Bad Request: required key id was not set or had empty value"}`, "")
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Nil(report)
|
suite.Nil(report)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,19 +21,13 @@
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BasePath = "/v1/reports"
|
BasePath = "/v1/reports"
|
||||||
IDKey = "id"
|
BasePathWithID = BasePath + "/:" + apiutil.IDKey
|
||||||
ResolvedKey = "resolved"
|
|
||||||
TargetAccountIDKey = "target_account_id"
|
|
||||||
MaxIDKey = "max_id"
|
|
||||||
SinceIDKey = "since_id"
|
|
||||||
MinIDKey = "min_id"
|
|
||||||
LimitKey = "limit"
|
|
||||||
BasePathWithID = BasePath + "/:" + IDKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Module struct {
|
type Module struct {
|
||||||
|
|
|
@ -18,14 +18,13 @@
|
||||||
package reports
|
package reports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportsGETHandler swagger:operation GET /api/v1/reports reports
|
// ReportsGETHandler swagger:operation GET /api/v1/reports reports
|
||||||
|
@ -67,7 +66,7 @@
|
||||||
// name: max_id
|
// name: max_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *OLDER* than the given max ID.
|
// Return only reports *OLDER* than the given max ID (for paging downwards).
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
|
@ -76,24 +75,21 @@
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *NEWER* than the given since ID.
|
// Return only reports *NEWER* than the given since ID.
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// This parameter is functionally equivalent to min_id.
|
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: min_id
|
// name: min_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only reports *NEWER* than the given min ID.
|
// Return only reports immediately *NEWER* than the given min ID (for paging upwards).
|
||||||
// The report with the specified ID will not be included in the response.
|
// The report with the specified ID will not be included in the response.
|
||||||
// This parameter is functionally equivalent to since_id.
|
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: limit
|
// name: limit
|
||||||
// type: integer
|
// type: integer
|
||||||
// description: >-
|
// description: Number of reports to return.
|
||||||
// Number of reports to return.
|
|
||||||
// If less than 1, will be clamped to 1.
|
|
||||||
// If more than 100, will be clamped to 100.
|
|
||||||
// default: 20
|
// default: 20
|
||||||
|
// minimum: 1
|
||||||
|
// maximum: 100
|
||||||
// in: query
|
// in: query
|
||||||
//
|
//
|
||||||
// security:
|
// security:
|
||||||
|
@ -134,36 +130,29 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolved *bool
|
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
|
||||||
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
|
if errWithCode != nil {
|
||||||
i, err := strconv.ParseBool(resolvedString)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", ResolvedKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolved = &i
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 20
|
|
||||||
if limitString := c.Query(LimitKey); limitString != "" {
|
|
||||||
i, err := strconv.Atoi(limitString)
|
|
||||||
if err != nil {
|
|
||||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
|
||||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize
|
page, errWithCode := paging.ParseIDPage(c,
|
||||||
if i <= 0 {
|
1, // min limit
|
||||||
i = 1
|
100, // max limit
|
||||||
} else if i >= 100 {
|
20, // default limit
|
||||||
i = 100
|
)
|
||||||
}
|
if errWithCode != nil {
|
||||||
limit = i
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, errWithCode := m.processor.Report().GetMultiple(c.Request.Context(), authed.Account, resolved, c.Query(TargetAccountIDKey), c.Query(MaxIDKey), c.Query(SinceIDKey), c.Query(MinIDKey), limit)
|
resp, errWithCode := m.processor.Report().GetMultiple(
|
||||||
|
c.Request.Context(),
|
||||||
|
authed.Account,
|
||||||
|
resolved,
|
||||||
|
c.Query(apiutil.TargetAccountIDKey),
|
||||||
|
page,
|
||||||
|
)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
@ -61,21 +62,21 @@ func (suite *ReportsGetTestSuite) getReports(
|
||||||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||||
|
|
||||||
// create the request URI
|
// create the request URI
|
||||||
requestPath := reports.BasePath + "?" + reports.LimitKey + "=" + strconv.Itoa(limit)
|
requestPath := reports.BasePath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
requestPath = requestPath + "&" + reports.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
|
||||||
}
|
}
|
||||||
if targetAccountID != "" {
|
if targetAccountID != "" {
|
||||||
requestPath = requestPath + "&" + reports.TargetAccountIDKey + "=" + targetAccountID
|
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
|
||||||
}
|
}
|
||||||
if maxID != "" {
|
if maxID != "" {
|
||||||
requestPath = requestPath + "&" + reports.MaxIDKey + "=" + maxID
|
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
|
||||||
}
|
}
|
||||||
if sinceID != "" {
|
if sinceID != "" {
|
||||||
requestPath = requestPath + "&" + reports.SinceIDKey + "=" + sinceID
|
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
|
||||||
}
|
}
|
||||||
if minID != "" {
|
if minID != "" {
|
||||||
requestPath = requestPath + "&" + reports.MinIDKey + "=" + minID
|
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
|
||||||
}
|
}
|
||||||
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
baseURI := config.GetProtocol() + "://" + config.GetHost()
|
||||||
requestURI := baseURI + "/api/" + requestPath
|
requestURI := baseURI + "/api/" + requestPath
|
||||||
|
|
|
@ -247,7 +247,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
|
||||||
Resolve: resolve,
|
Resolve: resolve,
|
||||||
Following: following,
|
Following: following,
|
||||||
ExcludeUnreviewed: excludeUnreviewed,
|
ExcludeUnreviewed: excludeUnreviewed,
|
||||||
AccountID: c.Query(apiutil.SearchAccountIDKey),
|
AccountID: c.Query(apiutil.AccountIDKey),
|
||||||
APIv1: apiVersion == apiutil.APIv1,
|
APIv1: apiVersion == apiutil.APIv1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ func (suite *SearchGetTestSuite) getSearch(
|
||||||
}
|
}
|
||||||
|
|
||||||
if fromAccountID != nil {
|
if fromAccountID != nil {
|
||||||
queryParts = append(queryParts, apiutil.SearchAccountIDKey+"="+url.QueryEscape(*fromAccountID))
|
queryParts = append(queryParts, apiutil.AccountIDKey+"="+url.QueryEscape(*fromAccountID))
|
||||||
}
|
}
|
||||||
|
|
||||||
requestURL.RawQuery = strings.Join(queryParts, "&")
|
requestURL.RawQuery = strings.Join(queryParts, "&")
|
||||||
|
|
|
@ -41,6 +41,9 @@
|
||||||
SinceIDKey = "since_id"
|
SinceIDKey = "since_id"
|
||||||
MinIDKey = "min_id"
|
MinIDKey = "min_id"
|
||||||
UsernameKey = "username"
|
UsernameKey = "username"
|
||||||
|
AccountIDKey = "account_id"
|
||||||
|
TargetAccountIDKey = "target_account_id"
|
||||||
|
ResolvedKey = "resolved"
|
||||||
|
|
||||||
/* AP endpoint keys */
|
/* AP endpoint keys */
|
||||||
|
|
||||||
|
@ -55,7 +58,6 @@
|
||||||
SearchQueryKey = "q"
|
SearchQueryKey = "q"
|
||||||
SearchResolveKey = "resolve"
|
SearchResolveKey = "resolve"
|
||||||
SearchTypeKey = "type"
|
SearchTypeKey = "type"
|
||||||
SearchAccountIDKey = "account_id"
|
|
||||||
|
|
||||||
/* Tag keys */
|
/* Tag keys */
|
||||||
|
|
||||||
|
@ -132,6 +134,10 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||||
return parseBool(value, defaultValue, LocalKey)
|
return parseBool(value, defaultValue, LocalKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseResolved(value string, defaultValue *bool) (*bool, gtserror.WithCode) {
|
||||||
|
return parseBoolPtr(value, defaultValue, ResolvedKey)
|
||||||
|
}
|
||||||
|
|
||||||
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
func ParseSearchExcludeUnreviewed(value string, defaultValue bool) (bool, gtserror.WithCode) {
|
||||||
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
|
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
|
||||||
}
|
}
|
||||||
|
@ -289,6 +295,19 @@ func parseBool(value string, defaultValue bool, key string) (bool, gtserror.With
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBoolPtr(value string, defaultValue *bool, key string) (*bool, gtserror.WithCode) {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue, parseError(key, value, defaultValue, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &i, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseInt(value string, defaultValue int, max int, min int, key string) (int, gtserror.WithCode) {
|
func parseInt(value string, defaultValue int, max int, min int, key string) (int, gtserror.WithCode) {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return defaultValue, nil
|
return defaultValue, nil
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
@ -27,6 +28,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
@ -51,14 +53,23 @@ func(report *gtsmodel.Report) error {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error) {
|
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error) {
|
||||||
reportIDs := []string{}
|
var (
|
||||||
|
// Get paging params.
|
||||||
|
minID = page.GetMin()
|
||||||
|
maxID = page.GetMax()
|
||||||
|
limit = page.GetLimit()
|
||||||
|
order = page.GetOrder()
|
||||||
|
|
||||||
|
// Make educated guess for slice size
|
||||||
|
reportIDs = make([]string, 0, limit)
|
||||||
|
)
|
||||||
|
|
||||||
q := r.db.
|
q := r.db.
|
||||||
NewSelect().
|
NewSelect().
|
||||||
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
|
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
|
||||||
Column("report.id").
|
// Select only IDs from table.
|
||||||
Order("report.id DESC")
|
Column("report.id")
|
||||||
|
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
i := bun.Ident("report.action_taken_by_account_id")
|
i := bun.Ident("report.action_taken_by_account_id")
|
||||||
|
@ -77,22 +88,32 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
|
||||||
q = q.Where("? = ?", bun.Ident("report.target_account_id"), targetAccountID)
|
q = q.Where("? = ?", bun.Ident("report.target_account_id"), targetAccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return only reports with id
|
||||||
|
// lower than provided maxID.
|
||||||
if maxID != "" {
|
if maxID != "" {
|
||||||
q = q.Where("? < ?", bun.Ident("report.id"), maxID)
|
q = q.Where("? < ?", bun.Ident("report.id"), maxID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sinceID != "" {
|
// Return only reports with id
|
||||||
q = q.Where("? > ?", bun.Ident("report.id"), minID)
|
// greater than provided minID.
|
||||||
}
|
|
||||||
|
|
||||||
if minID != "" {
|
if minID != "" {
|
||||||
q = q.Where("? > ?", bun.Ident("report.id"), minID)
|
q = q.Where("? > ?", bun.Ident("report.id"), minID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if limit != 0 {
|
if limit > 0 {
|
||||||
|
// Limit amount of
|
||||||
|
// reports returned.
|
||||||
q = q.Limit(limit)
|
q = q.Limit(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if order == paging.OrderAscending {
|
||||||
|
// Page up.
|
||||||
|
q = q.OrderExpr("? ASC", bun.Ident("report.id"))
|
||||||
|
} else {
|
||||||
|
// Page down.
|
||||||
|
q = q.OrderExpr("? DESC", bun.Ident("report.id"))
|
||||||
|
}
|
||||||
|
|
||||||
if err := q.Scan(ctx, &reportIDs); err != nil {
|
if err := q.Scan(ctx, &reportIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -102,6 +123,12 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
|
||||||
return nil, db.ErrNoEntries
|
return nil, db.ErrNoEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're paging up, we still want reports
|
||||||
|
// to be sorted by ID desc, so reverse ids slice.
|
||||||
|
if order == paging.OrderAscending {
|
||||||
|
slices.Reverse(reportIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// Allocate return slice (will be at most len reportIDs)
|
// Allocate return slice (will be at most len reportIDs)
|
||||||
reports := make([]*gtsmodel.Report, 0, len(reportIDs))
|
reports := make([]*gtsmodel.Report, 0, len(reportIDs))
|
||||||
for _, id := range reportIDs {
|
for _, id := range reportIDs {
|
||||||
|
|
|
@ -24,6 +24,8 @@
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
)
|
)
|
||||||
|
@ -61,14 +63,109 @@ func (suite *ReportTestSuite) TestGetReportByURI() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *ReportTestSuite) TestGetAllReports() {
|
func (suite *ReportTestSuite) TestGetAllReports() {
|
||||||
reports, err := suite.db.GetReports(context.Background(), nil, "", "", "", "", "", 0)
|
reports, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&paging.Page{},
|
||||||
|
)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.NotEmpty(reports)
|
suite.NotEmpty(reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *ReportTestSuite) TestReportPagingDown() {
|
||||||
|
// Get one from the top.
|
||||||
|
reports1, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&paging.Page{
|
||||||
|
Limit: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
if l := len(reports1); l != 1 {
|
||||||
|
suite.FailNowf("", "expected reports len 1, got %d", l)
|
||||||
|
}
|
||||||
|
id1 := reports1[0].ID
|
||||||
|
|
||||||
|
// Use this one to page down.
|
||||||
|
reports2, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&paging.Page{
|
||||||
|
Limit: 1,
|
||||||
|
Max: paging.MaxID(id1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
if l := len(reports2); l != 1 {
|
||||||
|
suite.FailNowf("", "expected reports len 1, got %d", l)
|
||||||
|
}
|
||||||
|
id2 := reports2[0].ID
|
||||||
|
|
||||||
|
suite.Greater(id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *ReportTestSuite) TestReportPagingUp() {
|
||||||
|
// Get one from the bottom.
|
||||||
|
reports1, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&paging.Page{
|
||||||
|
Limit: 1,
|
||||||
|
Min: paging.MinID(id.Lowest),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
if l := len(reports1); l != 1 {
|
||||||
|
suite.FailNowf("", "expected reports len 1, got %d", l)
|
||||||
|
}
|
||||||
|
id1 := reports1[0].ID
|
||||||
|
|
||||||
|
// Use this one to page up.
|
||||||
|
reports2, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
&paging.Page{
|
||||||
|
Limit: 1,
|
||||||
|
Min: paging.MinID(id1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
if l := len(reports2); l != 1 {
|
||||||
|
suite.FailNowf("", "expected reports len 1, got %d", l)
|
||||||
|
}
|
||||||
|
id2 := reports2[0].ID
|
||||||
|
|
||||||
|
suite.Less(id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *ReportTestSuite) TestGetAllReportsByAccountID() {
|
func (suite *ReportTestSuite) TestGetAllReportsByAccountID() {
|
||||||
accountID := suite.testAccounts["local_account_2"].ID
|
accountID := suite.testAccounts["local_account_2"].ID
|
||||||
reports, err := suite.db.GetReports(context.Background(), nil, accountID, "", "", "", "", 0)
|
reports, err := suite.db.GetReports(
|
||||||
|
context.Background(),
|
||||||
|
nil,
|
||||||
|
accountID,
|
||||||
|
"",
|
||||||
|
&paging.Page{},
|
||||||
|
)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.NotEmpty(reports)
|
suite.NotEmpty(reports)
|
||||||
for _, r := range reports {
|
for _, r := range reports {
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Report handles getting/creation/deletion/updating of user reports/flags.
|
// Report handles getting/creation/deletion/updating of user reports/flags.
|
||||||
|
@ -30,7 +31,7 @@ type Report interface {
|
||||||
|
|
||||||
// GetReports gets limit n reports using the given parameters.
|
// GetReports gets limit n reports using the given parameters.
|
||||||
// Parameters that are empty / zero are ignored.
|
// Parameters that are empty / zero are ignored.
|
||||||
GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error)
|
GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error)
|
||||||
|
|
||||||
// PopulateReport populates the struct pointers on the given report.
|
// PopulateReport populates the struct pointers on the given report.
|
||||||
PopulateReport(ctx context.Context, report *gtsmodel.Report) error
|
PopulateReport(ctx context.Context, report *gtsmodel.Report) error
|
||||||
|
|
|
@ -21,73 +21,81 @@
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportsGet returns all reports stored on this instance, with the given parameters.
|
// ReportsGet returns reports stored on this
|
||||||
|
// instance, with the given parameters.
|
||||||
func (p *Processor) ReportsGet(
|
func (p *Processor) ReportsGet(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
account *gtsmodel.Account,
|
account *gtsmodel.Account,
|
||||||
resolved *bool,
|
resolved *bool,
|
||||||
accountID string,
|
accountID string,
|
||||||
targetAccountID string,
|
targetAccountID string,
|
||||||
maxID string,
|
page *paging.Page,
|
||||||
sinceID string,
|
|
||||||
minID string,
|
|
||||||
limit int,
|
|
||||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
reports, err := p.state.DB.GetReports(ctx, resolved, accountID, targetAccountID, maxID, sinceID, minID, limit)
|
reports, err := p.state.DB.GetReports(
|
||||||
|
ctx,
|
||||||
|
resolved,
|
||||||
|
accountID,
|
||||||
|
targetAccountID,
|
||||||
|
page,
|
||||||
|
)
|
||||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count := len(reports)
|
count := len(reports)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return util.EmptyPageableResponse(), nil
|
return paging.EmptyResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// Get the lowest and highest
|
||||||
items = make([]interface{}, 0, count)
|
// ID values, used for paging.
|
||||||
nextMaxIDValue = reports[count-1].ID
|
lo := reports[count-1].ID
|
||||||
prevMinIDValue = reports[0].ID
|
hi := reports[0].ID
|
||||||
)
|
|
||||||
|
|
||||||
|
// Convert each report to API model.
|
||||||
|
items := make([]interface{}, 0, count)
|
||||||
for _, r := range reports {
|
for _, r := range reports {
|
||||||
item, err := p.converter.ReportToAdminAPIReport(ctx, r, account)
|
item, err := p.converter.ReportToAdminAPIReport(ctx, r, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err))
|
err := fmt.Errorf("error converting report to api: %s", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
extraQueryParams := make([]string, 0, 3)
|
// Assemble next/prev page queries.
|
||||||
|
query := make(url.Values, 3)
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
|
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
|
||||||
}
|
}
|
||||||
if accountID != "" {
|
if accountID != "" {
|
||||||
extraQueryParams = append(extraQueryParams, "account_id="+accountID)
|
query.Set(apiutil.AccountIDKey, accountID)
|
||||||
}
|
}
|
||||||
if targetAccountID != "" {
|
if targetAccountID != "" {
|
||||||
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
|
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return paging.PackageResponse(paging.ResponseParams{
|
||||||
Items: items,
|
Items: items,
|
||||||
Path: "/api/v1/admin/reports",
|
Path: "/api/v1/admin/reports",
|
||||||
NextMaxIDValue: nextMaxIDValue,
|
Next: page.Next(lo, hi),
|
||||||
PrevMinIDValue: prevMinIDValue,
|
Prev: page.Prev(lo, hi),
|
||||||
Limit: limit,
|
Query: query,
|
||||||
ExtraQueryParams: extraQueryParams,
|
}), nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportGet returns one report, with the given ID.
|
// ReportGet returns one report, with the given ID.
|
||||||
|
|
|
@ -21,13 +21,15 @@
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get returns the user view of a moderation report, with the given id.
|
// Get returns the user view of a moderation report, with the given id.
|
||||||
|
@ -53,53 +55,61 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin
|
||||||
return apiReport, nil
|
return apiReport, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMultiple returns multiple reports created by the given account, filtered according to the provided parameters.
|
// GetMultiple returns reports created by the given account,
|
||||||
|
// filtered according to the provided parameters.
|
||||||
func (p *Processor) GetMultiple(
|
func (p *Processor) GetMultiple(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
account *gtsmodel.Account,
|
account *gtsmodel.Account,
|
||||||
resolved *bool,
|
resolved *bool,
|
||||||
targetAccountID string,
|
targetAccountID string,
|
||||||
maxID string,
|
page *paging.Page,
|
||||||
sinceID string,
|
|
||||||
minID string,
|
|
||||||
limit int,
|
|
||||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
reports, err := p.state.DB.GetReports(ctx, resolved, account.ID, targetAccountID, maxID, sinceID, minID, limit)
|
reports, err := p.state.DB.GetReports(
|
||||||
|
ctx,
|
||||||
|
resolved,
|
||||||
|
account.ID,
|
||||||
|
targetAccountID,
|
||||||
|
page,
|
||||||
|
)
|
||||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count := len(reports)
|
count := len(reports)
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return util.EmptyPageableResponse(), nil
|
return paging.EmptyResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]interface{}, 0, count)
|
// Get the lowest and highest
|
||||||
nextMaxIDValue := reports[count-1].ID
|
// ID values, used for paging.
|
||||||
prevMinIDValue := reports[0].ID
|
lo := reports[count-1].ID
|
||||||
|
hi := reports[0].ID
|
||||||
|
|
||||||
|
// Convert each report to API model.
|
||||||
|
items := make([]interface{}, 0, count)
|
||||||
for _, r := range reports {
|
for _, r := range reports {
|
||||||
item, err := p.converter.ReportToAPIReport(ctx, r)
|
item, err := p.converter.ReportToAPIReport(ctx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting report to api: %s", err))
|
err := fmt.Errorf("error converting report to api: %s", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
extraQueryParams := []string{}
|
// Assemble next/prev page queries.
|
||||||
|
query := make(url.Values, 3)
|
||||||
if resolved != nil {
|
if resolved != nil {
|
||||||
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
|
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
|
||||||
}
|
}
|
||||||
if targetAccountID != "" {
|
if targetAccountID != "" {
|
||||||
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
|
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return paging.PackageResponse(paging.ResponseParams{
|
||||||
Items: items,
|
Items: items,
|
||||||
Path: "/api/v1/reports",
|
Path: "/api/v1/reports",
|
||||||
NextMaxIDValue: nextMaxIDValue,
|
Next: page.Next(lo, hi),
|
||||||
PrevMinIDValue: prevMinIDValue,
|
Prev: page.Prev(lo, hi),
|
||||||
Limit: limit,
|
Query: query,
|
||||||
ExtraQueryParams: extraQueryParams,
|
}), nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"sanitize-html": "^2.13.0",
|
||||||
"skulk": "^0.0.8-fix",
|
"skulk": "^0.0.8-fix",
|
||||||
"wouter": "^3.1.0"
|
"wouter": "^3.1.0"
|
||||||
},
|
},
|
||||||
|
@ -49,6 +50,7 @@
|
||||||
"@types/parse-link-header": "^2.0.3",
|
"@types/parse-link-header": "^2.0.3",
|
||||||
"@types/psl": "^1.1.1",
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react-dom": "^18.2.8",
|
"@types/react-dom": "^18.2.8",
|
||||||
|
"@types/sanitize-html": "^2.11.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.7.4",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
/*
|
|
||||||
GoToSocial
|
|
||||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
|
||||||
|
|
||||||
export default function FakeToot({ children }) {
|
|
||||||
const { data: account = {
|
|
||||||
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
|
|
||||||
display_name: "",
|
|
||||||
username: ""
|
|
||||||
} } = useVerifyCredentialsQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="status expanded">
|
|
||||||
<header className="status-header">
|
|
||||||
<address>
|
|
||||||
<a style={{margin: 0}}>
|
|
||||||
<img className="avatar" src={account.avatar} alt="" />
|
|
||||||
<dl className="author-strap">
|
|
||||||
<dt className="sr-only">Display name</dt>
|
|
||||||
<dd className="displayname text-cutoff">
|
|
||||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
|
||||||
</dd>
|
|
||||||
<dt className="sr-only">Username</dt>
|
|
||||||
<dd className="username text-cutoff">@{account.username}</dd>
|
|
||||||
</dl>
|
|
||||||
</a>
|
|
||||||
</address>
|
|
||||||
</header>
|
|
||||||
<section className="status-body">
|
|
||||||
<div className="text">
|
|
||||||
<div className="content">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
242
web/source/settings/components/status.tsx
Normal file
242
web/source/settings/components/status.tsx
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||||
|
import { MediaAttachment, Status as StatusType } from "../lib/types/status";
|
||||||
|
import sanitize from "sanitize-html";
|
||||||
|
|
||||||
|
export function FakeStatus({ children }) {
|
||||||
|
const { data: account = {
|
||||||
|
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
|
||||||
|
display_name: "",
|
||||||
|
username: ""
|
||||||
|
} } = useVerifyCredentialsQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="status expanded">
|
||||||
|
<header className="status-header">
|
||||||
|
<address>
|
||||||
|
<a style={{margin: 0}}>
|
||||||
|
<img className="avatar" src={account.avatar} alt="" />
|
||||||
|
<dl className="author-strap">
|
||||||
|
<dt className="sr-only">Display name</dt>
|
||||||
|
<dd className="displayname text-cutoff">
|
||||||
|
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
||||||
|
</dd>
|
||||||
|
<dt className="sr-only">Username</dt>
|
||||||
|
<dd className="username text-cutoff">@{account.username}</dd>
|
||||||
|
</dl>
|
||||||
|
</a>
|
||||||
|
</address>
|
||||||
|
</header>
|
||||||
|
<section className="status-body">
|
||||||
|
<div className="text">
|
||||||
|
<div className="content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Status({ status }: { status: StatusType }) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="status expanded"
|
||||||
|
id={status.id}
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
|
<StatusHeader status={status} />
|
||||||
|
<StatusBody status={status} />
|
||||||
|
<StatusFooter status={status} />
|
||||||
|
<a
|
||||||
|
href={status.url}
|
||||||
|
target="_blank"
|
||||||
|
className="status-link"
|
||||||
|
data-nosnippet
|
||||||
|
title="Open this status (opens in new tab)"
|
||||||
|
>
|
||||||
|
Open this status (opens in new tab)
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusHeader({ status }: { status: StatusType }) {
|
||||||
|
const author = status.account;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="status-header">
|
||||||
|
<address>
|
||||||
|
<a
|
||||||
|
href={author.url}
|
||||||
|
rel="author"
|
||||||
|
title="Open profile"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="avatar"
|
||||||
|
aria-hidden="true"
|
||||||
|
src={author.avatar}
|
||||||
|
alt={`Avatar for ${author.username}`}
|
||||||
|
title={`Avatar for ${author.username}`}
|
||||||
|
/>
|
||||||
|
<div className="author-strap">
|
||||||
|
<span className="displayname text-cutoff">{author.display_name}</span>
|
||||||
|
<span className="sr-only">,</span>
|
||||||
|
<span className="username text-cutoff">@{author.acct}</span>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">(open profile)</span>
|
||||||
|
</a>
|
||||||
|
</address>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBody({ status }: { status: StatusType }) {
|
||||||
|
let content: string;
|
||||||
|
if (status.content.length === 0) {
|
||||||
|
content = "[no content set]";
|
||||||
|
} else {
|
||||||
|
// HTML has already been through
|
||||||
|
// the instance sanitizer by now,
|
||||||
|
// but do it again just in case.
|
||||||
|
content = sanitize(status.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="status-body">
|
||||||
|
<details className="text-spoiler">
|
||||||
|
<summary>
|
||||||
|
<span
|
||||||
|
className="spoiler-text"
|
||||||
|
lang={status.language}
|
||||||
|
>
|
||||||
|
{ status.spoiler_text
|
||||||
|
? status.spoiler_text + " "
|
||||||
|
: "[no content warning set] "
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="button"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Toggle content visibility"
|
||||||
|
>
|
||||||
|
Toggle content visibility
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
className="text"
|
||||||
|
dangerouslySetInnerHTML={{__html: content}}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<StatusMedia status={status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusMedia({ status }: { status: StatusType }) {
|
||||||
|
if (status.media_attachments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = status.media_attachments.length;
|
||||||
|
const aria_label = count === 1 ? "1 attachment" : `${count} attachments`;
|
||||||
|
const oddOrEven = count % 2 === 0 ? "even" : "odd";
|
||||||
|
const single = count === 1 ? " single" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`media ${oddOrEven}${single}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={aria_label}
|
||||||
|
>
|
||||||
|
{ status.media_attachments.map((media) => {
|
||||||
|
return (
|
||||||
|
<StatusMediaEntry
|
||||||
|
key={media.id}
|
||||||
|
media={media}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusMediaEntry({ media }: { media: MediaAttachment }) {
|
||||||
|
return (
|
||||||
|
<div className="media-wrapper">
|
||||||
|
<details className="image-spoiler media-spoiler">
|
||||||
|
<summary>
|
||||||
|
<div className="show sensitive button" aria-hidden="true">Show media</div>
|
||||||
|
<span className="eye button" role="button" tabIndex={0} aria-label="Toggle show media">
|
||||||
|
<i className="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
|
||||||
|
<i className="show fa fa-fw fa-eye" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={media.preview_url}
|
||||||
|
loading="lazy"
|
||||||
|
alt={media.description}
|
||||||
|
title={media.description}
|
||||||
|
width={media.meta.small.width}
|
||||||
|
height={media.meta.small.height}
|
||||||
|
/>
|
||||||
|
</summary>
|
||||||
|
<a
|
||||||
|
href={media.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={media.url}
|
||||||
|
loading="lazy"
|
||||||
|
alt={media.description}
|
||||||
|
width={media.meta.original.width}
|
||||||
|
height={media.meta.original.height}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusFooter({ status }: { status: StatusType }) {
|
||||||
|
return (
|
||||||
|
<aside className="status-info" aria-hidden="true">
|
||||||
|
<dl className="status-stats">
|
||||||
|
<div className="stats-grouping">
|
||||||
|
<div className="stats-item published-at text-cutoff">
|
||||||
|
<dt className="sr-only">Published</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={status.created_at}>
|
||||||
|
{ new Date(status.created_at).toLocaleString() }
|
||||||
|
</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stats-item language">
|
||||||
|
<dt className="sr-only">Language</dt>
|
||||||
|
<dd>{status.language}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
|
@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }:
|
||||||
);
|
);
|
||||||
|
|
||||||
if (linkTo) {
|
if (linkTo) {
|
||||||
className += " spanlink";
|
className += " pseudolink";
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={className}
|
className={className}
|
||||||
|
|
|
@ -21,29 +21,51 @@ import { gtsApi } from "../../gts-api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminReport,
|
AdminReport,
|
||||||
AdminReportListParams,
|
AdminSearchReportParams,
|
||||||
AdminReportResolveParams,
|
AdminReportResolveParams,
|
||||||
|
AdminSearchReportResp,
|
||||||
} from "../../../types/report";
|
} from "../../../types/report";
|
||||||
|
import parse from "parse-link-header";
|
||||||
|
|
||||||
const extended = gtsApi.injectEndpoints({
|
const extended = gtsApi.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
listReports: build.query<AdminReport[], AdminReportListParams | void>({
|
searchReports: build.query<AdminSearchReportResp, AdminSearchReportParams>({
|
||||||
query: (params) => ({
|
query: (form) => {
|
||||||
url: "/api/v1/admin/reports",
|
const params = new(URLSearchParams);
|
||||||
params: {
|
Object.entries(form).forEach(([k, v]) => {
|
||||||
// Override provided limit.
|
if (v !== undefined) {
|
||||||
limit: 100,
|
params.append(k, v);
|
||||||
...params
|
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
providesTags: [{ type: "Reports", id: "LIST" }]
|
|
||||||
|
let query = "";
|
||||||
|
if (params.size !== 0) {
|
||||||
|
query = `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `/api/v1/admin/reports${query}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Headers required for paging.
|
||||||
|
transformResponse: (apiResp: AdminReport[], meta) => {
|
||||||
|
const accounts = apiResp;
|
||||||
|
const linksStr = meta?.response?.headers.get("Link");
|
||||||
|
const links = parse(linksStr);
|
||||||
|
return { accounts, links };
|
||||||
|
},
|
||||||
|
// Only provide LIST tag id since this model is not the
|
||||||
|
// same as getReport model (due to transformResponse).
|
||||||
|
providesTags: [{ type: "Report", id: "TRANSFORMED" }]
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getReport: build.query<AdminReport, string>({
|
getReport: build.query<AdminReport, string>({
|
||||||
query: (id) => ({
|
query: (id) => ({
|
||||||
url: `/api/v1/admin/reports/${id}`
|
url: `/api/v1/admin/reports/${id}`
|
||||||
}),
|
}),
|
||||||
providesTags: (_res, _error, id) => [{ type: "Reports", id }]
|
providesTags: (_result, _error, id) => [
|
||||||
|
{ type: 'Report', id }
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({
|
resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({
|
||||||
|
@ -55,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (res) =>
|
invalidatesTags: (res) =>
|
||||||
res
|
res
|
||||||
? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }]
|
? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
|
||||||
: [{ type: "Reports", id: "LIST" }]
|
: [{ type: "Report", id: "LIST" }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
@ -64,7 +86,7 @@ const extended = gtsApi.injectEndpoints({
|
||||||
/**
|
/**
|
||||||
* List reports received on this instance, filtered using given parameters.
|
* List reports received on this instance, filtered using given parameters.
|
||||||
*/
|
*/
|
||||||
const useListReportsQuery = extended.useListReportsQuery;
|
const useLazySearchReportsQuery = extended.useLazySearchReportsQuery;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single report by its ID.
|
* Get a single report by its ID.
|
||||||
|
@ -77,7 +99,7 @@ const useGetReportQuery = extended.useGetReportQuery;
|
||||||
const useResolveReportMutation = extended.useResolveReportMutation;
|
const useResolveReportMutation = extended.useResolveReportMutation;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useListReportsQuery,
|
useLazySearchReportsQuery,
|
||||||
useGetReportQuery,
|
useGetReportQuery,
|
||||||
useResolveReportMutation,
|
useResolveReportMutation,
|
||||||
};
|
};
|
||||||
|
|
|
@ -136,7 +136,7 @@ export const gtsApi = createApi({
|
||||||
tagTypes: [
|
tagTypes: [
|
||||||
"Auth",
|
"Auth",
|
||||||
"Emoji",
|
"Emoji",
|
||||||
"Reports",
|
"Report",
|
||||||
"Account",
|
"Account",
|
||||||
"InstanceRules",
|
"InstanceRules",
|
||||||
"HTTPHeaderAllows",
|
"HTTPHeaderAllows",
|
||||||
|
|
|
@ -17,6 +17,10 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Links } from "parse-link-header";
|
||||||
|
import { AdminAccount } from "./account";
|
||||||
|
import { Status } from "./status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin model of a report. Differs from the client
|
* Admin model of a report. Differs from the client
|
||||||
* model, which contains less detailed information.
|
* model, which contains less detailed information.
|
||||||
|
@ -56,29 +60,25 @@ export interface AdminReport {
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
/**
|
/**
|
||||||
* Account that created the report.
|
* Account that created the report.
|
||||||
* TODO: model this properly.
|
|
||||||
*/
|
*/
|
||||||
account: Object;
|
account: AdminAccount;
|
||||||
/**
|
/**
|
||||||
* Reported account.
|
* Reported account.
|
||||||
* TODO: model this properly.
|
|
||||||
*/
|
*/
|
||||||
target_account: Object;
|
target_account: AdminAccount;
|
||||||
/**
|
/**
|
||||||
* Admin account assigned to handle this report, if any.
|
* Admin account assigned to handle this report, if any.
|
||||||
* TODO: model this properly.
|
|
||||||
*/
|
*/
|
||||||
assigned_account?: Object;
|
assigned_account?: AdminAccount;
|
||||||
/**
|
/**
|
||||||
* Admin account that has taken action on this report, if any.
|
* Admin account that has taken action on this report, if any.
|
||||||
* TODO: model this properly.
|
|
||||||
*/
|
*/
|
||||||
action_taken_by_account?: Object;
|
action_taken_by_account?: AdminAccount;
|
||||||
/**
|
/**
|
||||||
* Statuses cited by this report, if any.
|
* Statuses cited by this report, if any.
|
||||||
* TODO: model this properly.
|
* TODO: model this properly.
|
||||||
*/
|
*/
|
||||||
statuses: Object[];
|
statuses: Status[];
|
||||||
/**
|
/**
|
||||||
* Rules broken according to the reporter, if any.
|
* Rules broken according to the reporter, if any.
|
||||||
* TODO: model this properly.
|
* TODO: model this properly.
|
||||||
|
@ -108,7 +108,7 @@ export interface AdminReportResolveParams {
|
||||||
/**
|
/**
|
||||||
* Parameters for GET to /api/v1/admin/reports.
|
* Parameters for GET to /api/v1/admin/reports.
|
||||||
*/
|
*/
|
||||||
export interface AdminReportListParams {
|
export interface AdminSearchReportParams {
|
||||||
/**
|
/**
|
||||||
* If set, show only resolved (true) or only unresolved (false) reports.
|
* If set, show only resolved (true) or only unresolved (false) reports.
|
||||||
*/
|
*/
|
||||||
|
@ -142,3 +142,8 @@ export interface AdminReportListParams {
|
||||||
*/
|
*/
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminSearchReportResp {
|
||||||
|
accounts: AdminReport[];
|
||||||
|
links: Links | null;
|
||||||
|
}
|
||||||
|
|
83
web/source/settings/lib/types/status.ts
Normal file
83
web/source/settings/lib/types/status.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Account } from "./account";
|
||||||
|
import { CustomEmoji } from "./custom-emoji";
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
in_reply_to_id: string | null;
|
||||||
|
in_reply_to_account_id: string | null;
|
||||||
|
sensitive: boolean;
|
||||||
|
spoiler_text: string;
|
||||||
|
visibility: string;
|
||||||
|
language: string;
|
||||||
|
uri: string;
|
||||||
|
url: string;
|
||||||
|
replies_count: number;
|
||||||
|
reblogs_count: number;
|
||||||
|
favourites_count: number;
|
||||||
|
favourited: boolean;
|
||||||
|
reblogged: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
bookmarked: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
content: string,
|
||||||
|
reblog: Status | null,
|
||||||
|
account: Account,
|
||||||
|
media_attachments: MediaAttachment[],
|
||||||
|
mentions: [];
|
||||||
|
tags: [];
|
||||||
|
emojis: CustomEmoji[];
|
||||||
|
card: null;
|
||||||
|
poll: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaAttachment {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
text_url: string;
|
||||||
|
preview_url: string;
|
||||||
|
remote_url: string | null;
|
||||||
|
preview_remote_url: string | null;
|
||||||
|
meta: MediaAttachmentMeta;
|
||||||
|
description: string;
|
||||||
|
blurhash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaAttachmentMeta {
|
||||||
|
original: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: string;
|
||||||
|
aspect: number;
|
||||||
|
},
|
||||||
|
small: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: string;
|
||||||
|
aspect: number;
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,8 +19,8 @@
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { AdminAccount } from "../../../../lib/types/account";
|
import { AdminAccount } from "../types/account";
|
||||||
import { store } from "../../../../redux/store";
|
import { store } from "../../redux/store";
|
||||||
|
|
||||||
export function yesOrNo(b: boolean): string {
|
export function yesOrNo(b: boolean): string {
|
||||||
return b ? "yes" : "no";
|
return b ? "yes" : "no";
|
|
@ -1045,62 +1045,62 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reports {
|
.reports-view {
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report {
|
.report {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0.5rem 0;
|
|
||||||
|
|
||||||
text-decoration: none;
|
|
||||||
color: $fg;
|
color: $fg;
|
||||||
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
border-left: 0.3rem solid $border-accent;
|
border-left: 0.3rem solid $border-accent;
|
||||||
|
|
||||||
.usernames {
|
.username-lozenge {
|
||||||
line-height: 2rem;
|
display: flex;
|
||||||
}
|
flex-wrap: nowrap;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
.byline {
|
.fa {
|
||||||
display: grid;
|
flex-shrink: 0;
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
.report-status {
|
|
||||||
color: $border-accent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.details {
|
.report-byline {
|
||||||
display: grid;
|
max-width: fit-content;
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 0.2rem 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
|
|
||||||
justify-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.info-list {
|
||||||
margin: 0;
|
border: none;
|
||||||
|
|
||||||
|
.info-list-entry {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.report-target .username-lozenge {
|
||||||
|
color: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported-by .username-lozenge {
|
||||||
|
color: $fg;
|
||||||
|
font-weight: initial;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.resolved {
|
&.resolved {
|
||||||
color: $fg-reduced;
|
border-left: 0.3rem solid $list-entry-bg;
|
||||||
border-left: 0.4rem solid $bg;
|
|
||||||
|
|
||||||
.byline .report-status {
|
.info-list,
|
||||||
|
.info-list .info-list-entry .reported-by .username-lozenge {
|
||||||
color: $fg-reduced;
|
color: $fg-reduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
&:hover {
|
||||||
opacity: 0.8;
|
border-color: $fg-accent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1109,70 +1109,40 @@ button.with-padding {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.report.detail {
|
.report-detail {
|
||||||
display: flex;
|
.info-list {
|
||||||
flex-direction: column;
|
|
||||||
|
&.overview {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
.info-block {
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: $gray2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reported-toots {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toot .toot-info {
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: $toot-info-bg;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: $fg-reduced;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom-left-radius: $br;
|
|
||||||
border-bottom-right-radius: $br;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.username-lozenge {
|
.username-lozenge {
|
||||||
line-height: 1.3rem;
|
display: flex;
|
||||||
display: inline-block;
|
flex-wrap: nowrap;
|
||||||
background: $fg-accent;
|
height: 100%;
|
||||||
color: $bg;
|
align-items: center;
|
||||||
border-radius: $br;
|
padding-top: 0;
|
||||||
padding: 0.15rem;
|
padding-bottom: 0;
|
||||||
font-weight: bold;
|
max-width: fit-content;
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
.acct {
|
.fa {
|
||||||
word-break: break-all;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.suspended {
|
|
||||||
background: $bg-accent;
|
|
||||||
color: $fg;
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.local {
|
|
||||||
background: $green1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spanlink {
|
.report-statuses {
|
||||||
cursor: pointer;
|
width: min(100%, 50rem);
|
||||||
text-decoration: none;
|
|
||||||
|
.thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.accounts-view {
|
.accounts-view {
|
||||||
|
@ -1223,6 +1193,36 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.username-lozenge {
|
||||||
|
line-height: 1.3rem;
|
||||||
|
display: inline-block;
|
||||||
|
background: $fg-accent;
|
||||||
|
color: $bg;
|
||||||
|
border-radius: $br;
|
||||||
|
padding: 0.15rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
.acct {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.suspended {
|
||||||
|
background: $bg-accent;
|
||||||
|
color: $fg;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.local {
|
||||||
|
background: $green1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pseudolink {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.info-list {
|
.info-list {
|
||||||
border: 0.1rem solid $gray1;
|
border: 0.1rem solid $gray1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { Redirect, useParams } from "wouter";
|
||||||
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
||||||
import useFormSubmit from "../../../../lib/form/submit";
|
import useFormSubmit from "../../../../lib/form/submit";
|
||||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||||
import FakeToot from "../../../../components/fake-toot";
|
import { FakeStatus } from "../../../../components/status";
|
||||||
import FormWithData from "../../../../lib/form/form-with-data";
|
import FormWithData from "../../../../lib/form/form-with-data";
|
||||||
import Loading from "../../../../components/loading";
|
import Loading from "../../../../components/loading";
|
||||||
import { FileInput } from "../../../../components/form/inputs";
|
import { FileInput } from "../../../../components/form/inputs";
|
||||||
|
@ -124,14 +124,14 @@ function EmojiDetailForm({ data: emoji }) {
|
||||||
disabled={!form.image.value}
|
disabled={!form.image.value}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FakeToot>
|
<FakeStatus>
|
||||||
Look at this new custom emoji <img
|
Look at this new custom emoji <img
|
||||||
className="emoji"
|
className="emoji"
|
||||||
src={form.image.previewValue ?? emoji.url}
|
src={form.image.previewValue ?? emoji.url}
|
||||||
title={`:${emoji.shortcode}:`}
|
title={`:${emoji.shortcode}:`}
|
||||||
alt={emoji.shortcode}
|
alt={emoji.shortcode}
|
||||||
/> isn't it cool?
|
/> isn't it cool?
|
||||||
</FakeToot>
|
</FakeStatus>
|
||||||
|
|
||||||
{result.error && <Error error={result.error} />}
|
{result.error && <Error error={result.error} />}
|
||||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||||
|
|
|
@ -23,7 +23,7 @@ import useShortcode from "./use-shortcode";
|
||||||
import useFormSubmit from "../../../../lib/form/submit";
|
import useFormSubmit from "../../../../lib/form/submit";
|
||||||
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
||||||
import { CategorySelect } from '../category-select';
|
import { CategorySelect } from '../category-select';
|
||||||
import FakeToot from "../../../../components/fake-toot";
|
import { FakeStatus } from "../../../../components/status";
|
||||||
import MutationButton from "../../../../components/form/mutation-button";
|
import MutationButton from "../../../../components/form/mutation-button";
|
||||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||||
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
|
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
|
||||||
|
@ -103,9 +103,9 @@ export default function NewEmojiForm() {
|
||||||
<div>
|
<div>
|
||||||
<h2>Add new custom emoji</h2>
|
<h2>Add new custom emoji</h2>
|
||||||
|
|
||||||
<FakeToot>
|
<FakeStatus>
|
||||||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||||
</FakeToot>
|
</FakeStatus>
|
||||||
|
|
||||||
<form onSubmit={submitForm} className="form-flex">
|
<form onSubmit={submitForm} className="form-flex">
|
||||||
<FileInput
|
<FileInput
|
||||||
|
|
|
@ -69,7 +69,7 @@ export default function HeaderPermsOverview() {
|
||||||
return (
|
return (
|
||||||
<dl
|
<dl
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className="entry spanlink"
|
className="entry pseudolink"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// When clicking on a header perm,
|
// When clicking on a header perm,
|
||||||
// go to the detail view for perm.
|
// go to the detail view for perm.
|
||||||
|
|
|
@ -21,13 +21,13 @@ import React from "react";
|
||||||
|
|
||||||
import { useGetAccountQuery } from "../../../../lib/query/admin";
|
import { useGetAccountQuery } from "../../../../lib/query/admin";
|
||||||
import FormWithData from "../../../../lib/form/form-with-data";
|
import FormWithData from "../../../../lib/form/form-with-data";
|
||||||
import FakeProfile from "../../../../components/fake-profile";
|
import FakeProfile from "../../../../components/profile";
|
||||||
import { AdminAccount } from "../../../../lib/types/account";
|
import { AdminAccount } from "../../../../lib/types/account";
|
||||||
import { AccountActions } from "./actions";
|
import { AccountActions } from "./actions";
|
||||||
import { useParams } from "wouter";
|
import { useParams } from "wouter";
|
||||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||||
import BackButton from "../../../../components/back-button";
|
import BackButton from "../../../../components/back-button";
|
||||||
import { UseOurInstanceAccount, yesOrNo } from "./util";
|
import { UseOurInstanceAccount, yesOrNo } from "../../../../lib/util";
|
||||||
|
|
||||||
export default function AccountDetail() {
|
export default function AccountDetail() {
|
||||||
const params: { accountID: string } = useParams();
|
const params: { accountID: string } = useParams();
|
||||||
|
|
|
@ -83,7 +83,7 @@ export function AccountSearchForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location to return to when user clicks "back" on the account detail view.
|
// Location to return to when user clicks "back" on the account detail view.
|
||||||
const backLocation = location + (urlQueryParams ? `?${urlQueryParams}` : "");
|
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
|
||||||
|
|
||||||
// Function to map an item to a list entry.
|
// Function to map an item to a list entry.
|
||||||
function itemToEntry(account: AdminAccount): ReactNode {
|
function itemToEntry(account: AdminAccount): ReactNode {
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React from "react";
|
||||||
import { useParams } from "wouter";
|
import { useLocation, useParams } from "wouter";
|
||||||
import FormWithData from "../../../lib/form/form-with-data";
|
import FormWithData from "../../../lib/form/form-with-data";
|
||||||
import BackButton from "../../../components/back-button";
|
import BackButton from "../../../components/back-button";
|
||||||
import { useValue, useTextInput } from "../../../lib/form";
|
import { useValue, useTextInput } from "../../../lib/form";
|
||||||
|
@ -28,84 +28,172 @@ import MutationButton from "../../../components/form/mutation-button";
|
||||||
import Username from "../../../components/username";
|
import Username from "../../../components/username";
|
||||||
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
||||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||||
|
import { AdminReport } from "../../../lib/types/report";
|
||||||
|
import { yesOrNo } from "../../../lib/util";
|
||||||
|
import { Status } from "../../../components/status";
|
||||||
|
|
||||||
export default function ReportDetail({ }) {
|
export default function ReportDetail({ }) {
|
||||||
|
const params: { reportId: string } = useParams();
|
||||||
const baseUrl = useBaseUrl();
|
const baseUrl = useBaseUrl();
|
||||||
const params = useParams();
|
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reports">
|
<div className="report-detail">
|
||||||
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
|
<h1><BackButton to={backLocation}/> Report Details</h1>
|
||||||
<FormWithData
|
<FormWithData
|
||||||
dataQuery={useGetReportQuery}
|
dataQuery={useGetReportQuery}
|
||||||
queryArg={params.reportId}
|
queryArg={params.reportId}
|
||||||
DataForm={ReportDetailForm}
|
DataForm={ReportDetailForm}
|
||||||
|
{...{ backLocation: backLocation }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportDetailForm({ data: report }) {
|
function ReportDetailForm({ data: report }: { data: AdminReport }) {
|
||||||
const from = report.account;
|
const [ location ] = useLocation();
|
||||||
const target = report.target_account;
|
const baseUrl = useBaseUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="report detail">
|
<>
|
||||||
<div className="usernames">
|
<ReportBasicInfo
|
||||||
<Username
|
report={report}
|
||||||
account={from}
|
baseUrl={baseUrl}
|
||||||
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
location={location}
|
||||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
|
||||||
/>
|
/>
|
||||||
<> reported </>
|
|
||||||
|
{ report.action_taken
|
||||||
|
&& <ReportHistory
|
||||||
|
report={report}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
location={location}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
{ report.statuses &&
|
||||||
|
<ReportStatuses report={report} />
|
||||||
|
}
|
||||||
|
|
||||||
|
{ !report.action_taken &&
|
||||||
|
<ReportActionForm report={report} />
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportSectionProps {
|
||||||
|
report: AdminReport;
|
||||||
|
baseUrl: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
|
||||||
|
const from = report.account;
|
||||||
|
const target = report.target_account;
|
||||||
|
const comment = report.comment;
|
||||||
|
const status = report.action_taken ? "Resolved" : "Unresolved";
|
||||||
|
const created = new Date(report.created_at).toLocaleString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="info-list overview">
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Reported account</dt>
|
||||||
|
<dd>
|
||||||
<Username
|
<Username
|
||||||
account={target}
|
account={target}
|
||||||
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
||||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
backLocation={`~${baseUrl}${location}`}
|
||||||
/>
|
/>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.action_taken &&
|
<div className="info-list-entry">
|
||||||
<div className="info">
|
<dt>Reported by</dt>
|
||||||
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
|
<dd>
|
||||||
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
|
<Username
|
||||||
<br />
|
account={from}
|
||||||
<b>Comment: </b><span>{report.action_taken_comment}</span>
|
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
||||||
|
backLocation={`~${baseUrl}${location}`}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
{ report.action_taken
|
||||||
|
? <>{status}</>
|
||||||
|
: <b>{status}</b>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>
|
||||||
|
{ comment.length > 0
|
||||||
|
? <>{comment}</>
|
||||||
|
: <i>none provided</i>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={report.created_at}>{created}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Category</dt>
|
||||||
|
<dd>{ report.category }</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Forwarded</dt>
|
||||||
|
<dd>{ yesOrNo(report.forwarded) }</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="info-block">
|
function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
|
||||||
<h3>Report info:</h3>
|
const handled_by = report.action_taken_by_account;
|
||||||
<div className="details">
|
if (!handled_by) {
|
||||||
<b>Created: </b>
|
throw "report handled by action_taken_by_account undefined";
|
||||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
|
||||||
|
|
||||||
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
|
|
||||||
<b>Category: </b> <span>{report.category}</span>
|
|
||||||
|
|
||||||
<b>Reason: </b>
|
|
||||||
{report.comment.length > 0
|
|
||||||
? <p>{report.comment}</p>
|
|
||||||
: <i className="no-comment">none provided</i>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
const handled = report.action_taken_at ? new Date(report.action_taken_at).toLocaleString() : "never";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>Moderation History</h3>
|
||||||
|
<dl className="info-list">
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Handled by</dt>
|
||||||
|
<dd>
|
||||||
|
<Username
|
||||||
|
account={handled_by}
|
||||||
|
linkTo={`~/settings/moderation/accounts/${handled_by.id}`}
|
||||||
|
backLocation={`~${baseUrl}${location}`}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!report.action_taken && <ReportActionForm report={report} />}
|
<div className="info-list-entry">
|
||||||
|
<dt>Handled</dt>
|
||||||
|
<dd>
|
||||||
|
<time dateTime={report.action_taken_at}>{handled}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
{
|
<div className="info-list-entry">
|
||||||
report.statuses.length > 0 &&
|
<dt>Comment</dt>
|
||||||
<div className="info-block">
|
<dd>{ report.action_taken_comment ?? "none"}</dd>
|
||||||
<h3>Reported toots ({report.statuses.length}):</h3>
|
|
||||||
<div className="reported-toots">
|
|
||||||
{report.statuses.map((status) => (
|
|
||||||
<ReportedToot key={status.id} toot={status} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,13 +206,18 @@ function ReportActionForm({ report }) {
|
||||||
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
|
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="info-block">
|
<form onSubmit={submit}>
|
||||||
<h3>Resolving this report</h3>
|
<h3>Resolve this report</h3>
|
||||||
<p>
|
<>
|
||||||
An optional comment can be included while resolving this report.
|
An optional comment can be included while resolving this report.
|
||||||
Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br />
|
This is useful for providing an explanation about what action was
|
||||||
<b>This will be visible to the user that created the report!</b>
|
taken (if any) before the report was marked as resolved.
|
||||||
</p>
|
<br />
|
||||||
|
<b>
|
||||||
|
Any comment made here will be visible
|
||||||
|
to the user that created the report!
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
<TextArea
|
<TextArea
|
||||||
field={form.comment}
|
field={form.comment}
|
||||||
label="Comment"
|
label="Comment"
|
||||||
|
@ -138,116 +231,24 @@ function ReportActionForm({ report }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportedToot({ toot }) {
|
function ReportStatuses({ report }: { report: AdminReport }) {
|
||||||
const account = toot.account;
|
if (report.statuses.length === 0) {
|
||||||
|
return null;
|
||||||
return (
|
|
||||||
<article className="status expanded">
|
|
||||||
<header className="status-header">
|
|
||||||
<address>
|
|
||||||
<a style={{margin: 0}}>
|
|
||||||
<img className="avatar" src={account.avatar} alt="" />
|
|
||||||
<dl className="author-strap">
|
|
||||||
<dt className="sr-only">Display name</dt>
|
|
||||||
<dd className="displayname text-cutoff">
|
|
||||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
|
||||||
</dd>
|
|
||||||
<dt className="sr-only">Username</dt>
|
|
||||||
<dd className="username text-cutoff">@{account.username}</dd>
|
|
||||||
</dl>
|
|
||||||
</a>
|
|
||||||
</address>
|
|
||||||
</header>
|
|
||||||
<section className="status-body">
|
|
||||||
<div className="text">
|
|
||||||
<div className="content">
|
|
||||||
{toot.spoiler_text?.length > 0
|
|
||||||
? <TootCW content={toot.content} note={toot.spoiler_text} />
|
|
||||||
: toot.content
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{toot.media_attachments?.length > 0 &&
|
|
||||||
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
<aside className="status-info">
|
|
||||||
<dl className="status-stats">
|
|
||||||
<div className="stats-grouping">
|
|
||||||
<div className="stats-item published-at text-cutoff">
|
|
||||||
<dt className="sr-only">Published</dt>
|
|
||||||
<dd>
|
|
||||||
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</aside>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TootCW({ note, content }) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
function toggleVisible() {
|
|
||||||
setVisible(!visible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="report-statuses">
|
||||||
<div className="spoiler">
|
<h3>Reported Statuses</h3>
|
||||||
<span>{note}</span>
|
<ul className="thread">
|
||||||
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
|
{ report.statuses.map((status) => {
|
||||||
</div>
|
|
||||||
{visible && content}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TootMedia({ media, sensitive }) {
|
|
||||||
let classes = (media.length % 2 == 0) ? "even" : "odd";
|
|
||||||
if (media.length == 1) {
|
|
||||||
classes += " single";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`media photoswipe-gallery ${classes}`}>
|
<Status
|
||||||
{media.map((m) => (
|
key={status.id}
|
||||||
<div key={m.id} className="media-wrapper">
|
status={status}
|
||||||
{sensitive && <>
|
|
||||||
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
|
|
||||||
<div className="sensitive">
|
|
||||||
<div className="open">
|
|
||||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
|
||||||
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="closed" title={m.description}>
|
|
||||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
|
||||||
Show sensitive media
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>}
|
|
||||||
<a
|
|
||||||
href={m.url}
|
|
||||||
title={m.description}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
data-cropped="true"
|
|
||||||
data-pswp-width={`${m.meta?.original.width}px`}
|
|
||||||
data-pswp-height={`${m.meta?.original.height}px`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt={m.description}
|
|
||||||
src={m.url}
|
|
||||||
// thumb={m.preview_url}
|
|
||||||
sizes={m.meta?.original}
|
|
||||||
/>
|
/>
|
||||||
</a>
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
/*
|
|
||||||
GoToSocial
|
|
||||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import FormWithData from "../../../lib/form/form-with-data";
|
|
||||||
import Username from "../../../components/username";
|
|
||||||
import { useListReportsQuery } from "../../../lib/query/admin/reports";
|
|
||||||
|
|
||||||
export function ReportOverview({ }) {
|
|
||||||
return (
|
|
||||||
<FormWithData
|
|
||||||
dataQuery={useListReportsQuery}
|
|
||||||
DataForm={ReportsList}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportsList({ data: reports }) {
|
|
||||||
return (
|
|
||||||
<div className="reports">
|
|
||||||
<div className="form-section-docs">
|
|
||||||
<h1>Reports</h1>
|
|
||||||
<p>
|
|
||||||
Here you can view and resolve reports made to your
|
|
||||||
instance, originating from local and remote users.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
|
|
||||||
target="_blank"
|
|
||||||
className="docslink"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Learn more about this (opens in a new tab)
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="list">
|
|
||||||
{reports.map((report) => (
|
|
||||||
<ReportEntry key={report.id} report={report} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportEntry({ report }) {
|
|
||||||
const from = report.account;
|
|
||||||
const target = report.target_account;
|
|
||||||
|
|
||||||
let comment = report.comment.length > 200
|
|
||||||
? report.comment.slice(0, 200) + "..."
|
|
||||||
: report.comment;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/${report.id}`}
|
|
||||||
className="nounderline"
|
|
||||||
>
|
|
||||||
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
|
||||||
<div className="byline">
|
|
||||||
<div className="usernames">
|
|
||||||
<Username account={from} /> reported <Username account={target} />
|
|
||||||
</div>
|
|
||||||
<h3 className="report-status">
|
|
||||||
{report.action_taken ? "Resolved" : "Open"}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="details">
|
|
||||||
<b>Created: </b>
|
|
||||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
|
||||||
|
|
||||||
<b>Reason: </b>
|
|
||||||
{comment.length > 0
|
|
||||||
? <p>{comment}</p>
|
|
||||||
: <i className="no-comment">none provided</i>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
252
web/source/settings/views/moderation/reports/search.tsx
Normal file
252
web/source/settings/views/moderation/reports/search.tsx
Normal file
|
@ -0,0 +1,252 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ReactNode, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
import { useLazySearchReportsQuery } from "../../../lib/query/admin/reports";
|
||||||
|
import { useTextInput } from "../../../lib/form";
|
||||||
|
import { PageableList } from "../../../components/pageable-list";
|
||||||
|
import { Select } from "../../../components/form/inputs";
|
||||||
|
import MutationButton from "../../../components/form/mutation-button";
|
||||||
|
import { useLocation, useSearch } from "wouter";
|
||||||
|
import Username from "../../../components/username";
|
||||||
|
import { AdminReport } from "../../../lib/types/report";
|
||||||
|
|
||||||
|
export default function ReportsSearch() {
|
||||||
|
return (
|
||||||
|
<div className="reports-view">
|
||||||
|
<h1>Reports Search</h1>
|
||||||
|
<span>
|
||||||
|
You can use the form below to search through reports
|
||||||
|
created by, or directed towards, accounts on this instance.
|
||||||
|
</span>
|
||||||
|
<ReportSearchForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSearchForm() {
|
||||||
|
const [ location, setLocation ] = useLocation();
|
||||||
|
const search = useSearch();
|
||||||
|
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||||
|
const hasParams = urlQueryParams.size != 0;
|
||||||
|
const [ searchReports, searchRes ] = useLazySearchReportsQuery();
|
||||||
|
|
||||||
|
// Populate search form using values from
|
||||||
|
// urlQueryParams, to allow paging.
|
||||||
|
const resolved = useMemo(() => {
|
||||||
|
const resolvedRaw = urlQueryParams.get("resolved");
|
||||||
|
if (resolvedRaw !== null) {
|
||||||
|
return resolvedRaw;
|
||||||
|
}
|
||||||
|
}, [urlQueryParams]);
|
||||||
|
|
||||||
|
const form = {
|
||||||
|
resolved: useTextInput("resolved", { defaultValue: resolved }),
|
||||||
|
account_id: useTextInput("account_id", { defaultValue: urlQueryParams.get("account_id") ?? "" }),
|
||||||
|
target_account_id: useTextInput("target_account_id", { defaultValue: urlQueryParams.get("target_account_id") ?? "" }),
|
||||||
|
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
|
||||||
|
};
|
||||||
|
|
||||||
|
const setResolved = form.resolved.setter;
|
||||||
|
|
||||||
|
// On mount, if urlQueryParams were provided,
|
||||||
|
// trigger the search. For example, if page
|
||||||
|
// was accessed at /search?origin=local&limit=20,
|
||||||
|
// then run a search with origin=local and
|
||||||
|
// limit=20 and immediately render the results.
|
||||||
|
//
|
||||||
|
// If no urlQueryParams set, use the default
|
||||||
|
// search (just show unresolved reports).
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasParams) {
|
||||||
|
searchReports(Object.fromEntries(urlQueryParams));
|
||||||
|
} else {
|
||||||
|
setResolved("false");
|
||||||
|
setLocation(location + "?resolved=false");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
urlQueryParams,
|
||||||
|
hasParams,
|
||||||
|
searchReports,
|
||||||
|
location,
|
||||||
|
setLocation,
|
||||||
|
setResolved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Rather than triggering the search directly,
|
||||||
|
// the "submit" button changes the location
|
||||||
|
// based on form field params, and lets the
|
||||||
|
// useEffect hook above actually do the search.
|
||||||
|
function submitQuery(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Parse query parameters.
|
||||||
|
const entries = Object.entries(form).map(([k, v]) => {
|
||||||
|
// Take only defined form fields.
|
||||||
|
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [[k, v.value]];
|
||||||
|
}).flatMap(kv => {
|
||||||
|
// Remove any nulls.
|
||||||
|
return kv || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(entries);
|
||||||
|
setLocation(location + "?" + searchParams.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location to return to when user clicks "back" on the detail view.
|
||||||
|
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
|
||||||
|
|
||||||
|
// Function to map an item to a list entry.
|
||||||
|
function itemToEntry(report: AdminReport): ReactNode {
|
||||||
|
return (
|
||||||
|
<ReportListEntry
|
||||||
|
key={report.id}
|
||||||
|
report={report}
|
||||||
|
linkTo={`/${report.id}`}
|
||||||
|
backLocation={backLocation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
onSubmit={submitQuery}
|
||||||
|
// Prevent password managers
|
||||||
|
// trying to fill in fields.
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
field={form.resolved}
|
||||||
|
label="Report status"
|
||||||
|
options={
|
||||||
|
<>
|
||||||
|
<option value="false">Unresolved only</option>
|
||||||
|
<option value="true">Resolved only</option>
|
||||||
|
<option value="">Any</option>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Select>
|
||||||
|
<MutationButton
|
||||||
|
disabled={false}
|
||||||
|
label={"Search"}
|
||||||
|
result={searchRes}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<PageableList
|
||||||
|
isLoading={searchRes.isLoading}
|
||||||
|
isFetching={searchRes.isFetching}
|
||||||
|
isSuccess={searchRes.isSuccess}
|
||||||
|
items={searchRes.data?.accounts}
|
||||||
|
itemToEntry={itemToEntry}
|
||||||
|
isError={searchRes.isError}
|
||||||
|
error={searchRes.error}
|
||||||
|
emptyMessage={<b>No reports found that match your query.</b>}
|
||||||
|
prevNextLinks={searchRes.data?.links}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportEntryProps {
|
||||||
|
report: AdminReport;
|
||||||
|
linkTo: string;
|
||||||
|
backLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
|
||||||
|
const [ _location, setLocation ] = useLocation();
|
||||||
|
|
||||||
|
const from = report.account;
|
||||||
|
const target = report.target_account;
|
||||||
|
const comment = report.comment;
|
||||||
|
const status = report.action_taken ? "Resolved" : "Unresolved";
|
||||||
|
const created = new Date(report.created_at).toLocaleString();
|
||||||
|
const title = `${status}. @${target.account.acct} was reported by @${from.account.acct} on ${created}. Reason: "${comment}"`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`pseudolink report entry${report.action_taken ? " resolved" : ""}`}
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
onClick={() => {
|
||||||
|
// When clicking on a report, direct
|
||||||
|
// to the detail view for that report.
|
||||||
|
setLocation(linkTo, {
|
||||||
|
// Store the back location in history so
|
||||||
|
// the detail view can use it to return to
|
||||||
|
// this page (including query parameters).
|
||||||
|
state: { backLocation: backLocation }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<dl className="info-list">
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Reported account:</dt>
|
||||||
|
<dd className="text-cutoff">
|
||||||
|
<Username
|
||||||
|
account={target}
|
||||||
|
classNames={["text-cutoff report-byline"]}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Reported by:</dt>
|
||||||
|
<dd className="text-cutoff reported-by">
|
||||||
|
<Username account={from} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Status:</dt>
|
||||||
|
<dd className="text-cutoff">
|
||||||
|
{ report.action_taken
|
||||||
|
? <>{status}</>
|
||||||
|
: <b>{status}</b>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Reason:</dt>
|
||||||
|
<dd className="text-cutoff">
|
||||||
|
{ comment.length > 0
|
||||||
|
? <>{comment}</>
|
||||||
|
: <i>none provided</i>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-list-entry">
|
||||||
|
<dt>Created:</dt>
|
||||||
|
<dd className="text-cutoff">
|
||||||
|
<time dateTime={report.created_at}>{created}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
|
@ -20,7 +20,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
|
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
|
||||||
import { Redirect, Route, Router, Switch } from "wouter";
|
import { Redirect, Route, Router, Switch } from "wouter";
|
||||||
import { ReportOverview } from "./reports/overview";
|
import ReportsSearch from "./reports/search";
|
||||||
import ReportDetail from "./reports/detail";
|
import ReportDetail from "./reports/detail";
|
||||||
import { ErrorBoundary } from "../../lib/navigation/error";
|
import { ErrorBoundary } from "../../lib/navigation/error";
|
||||||
import ImportExport from "./domain-permissions/import-export";
|
import ImportExport from "./domain-permissions/import-export";
|
||||||
|
@ -85,8 +85,9 @@ function ModerationReportsRouter() {
|
||||||
<Router base={thisBase}>
|
<Router base={thisBase}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/search" component={ReportsSearch}/>
|
||||||
<Route path={"/:reportId"} component={ReportDetail} />
|
<Route path={"/:reportId"} component={ReportDetail} />
|
||||||
<Route component={ReportOverview}/>
|
<Route><Redirect to="/search"/></Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
@ -39,7 +39,7 @@ import {
|
||||||
} from "../../components/form/inputs";
|
} from "../../components/form/inputs";
|
||||||
|
|
||||||
import FormWithData from "../../lib/form/form-with-data";
|
import FormWithData from "../../lib/form/form-with-data";
|
||||||
import FakeProfile from "../../components/fake-profile";
|
import FakeProfile from "../../components/profile";
|
||||||
import MutationButton from "../../components/form/mutation-button";
|
import MutationButton from "../../components/form/mutation-button";
|
||||||
|
|
||||||
import { useAccountThemesQuery } from "../../lib/query/user";
|
import { useAccountThemesQuery } from "../../lib/query/user";
|
||||||
|
|
|
@ -1499,6 +1499,13 @@
|
||||||
"@types/scheduler" "*"
|
"@types/scheduler" "*"
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/sanitize-html@^2.11.0":
|
||||||
|
version "2.11.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.11.0.tgz#582d8c72215c0228e3af2be136e40e0b531addf2"
|
||||||
|
integrity sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==
|
||||||
|
dependencies:
|
||||||
|
htmlparser2 "^8.0.0"
|
||||||
|
|
||||||
"@types/scheduler@*":
|
"@types/scheduler@*":
|
||||||
version "0.16.4"
|
version "0.16.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
|
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
|
||||||
|
@ -3125,11 +3132,41 @@ doctrine@^3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils "^2.0.2"
|
esutils "^2.0.2"
|
||||||
|
|
||||||
|
dom-serializer@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
|
||||||
|
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
|
||||||
|
dependencies:
|
||||||
|
domelementtype "^2.3.0"
|
||||||
|
domhandler "^5.0.2"
|
||||||
|
entities "^4.2.0"
|
||||||
|
|
||||||
domain-browser@^1.2.0:
|
domain-browser@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
||||||
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
|
integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
|
||||||
|
|
||||||
|
domelementtype@^2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
|
||||||
|
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
|
||||||
|
|
||||||
|
domhandler@^5.0.2, domhandler@^5.0.3:
|
||||||
|
version "5.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
|
||||||
|
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
|
||||||
|
dependencies:
|
||||||
|
domelementtype "^2.3.0"
|
||||||
|
|
||||||
|
domutils@^3.0.1:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
|
||||||
|
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
|
||||||
|
dependencies:
|
||||||
|
dom-serializer "^2.0.0"
|
||||||
|
domelementtype "^2.3.0"
|
||||||
|
domhandler "^5.0.3"
|
||||||
|
|
||||||
drange@^1.0.2:
|
drange@^1.0.2:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8"
|
resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8"
|
||||||
|
@ -3198,6 +3235,11 @@ enhanced-resolve@^5.0.0:
|
||||||
graceful-fs "^4.2.4"
|
graceful-fs "^4.2.4"
|
||||||
tapable "^2.2.0"
|
tapable "^2.2.0"
|
||||||
|
|
||||||
|
entities@^4.2.0, entities@^4.4.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
|
||||||
|
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
|
||||||
|
|
||||||
error-ex@^1.2.0:
|
error-ex@^1.2.0:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
|
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
|
||||||
|
@ -4041,6 +4083,16 @@ htmlescape@^1.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
|
resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
|
||||||
integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==
|
integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg==
|
||||||
|
|
||||||
|
htmlparser2@^8.0.0:
|
||||||
|
version "8.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
|
||||||
|
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
|
||||||
|
dependencies:
|
||||||
|
domelementtype "^2.3.0"
|
||||||
|
domhandler "^5.0.3"
|
||||||
|
domutils "^3.0.1"
|
||||||
|
entities "^4.4.0"
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
|
||||||
|
@ -4944,6 +4996,11 @@ nanoid@^3.3.6:
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
|
||||||
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
|
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
|
||||||
|
|
||||||
|
nanoid@^3.3.7:
|
||||||
|
version "3.3.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||||
|
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||||
|
|
||||||
nanoid@^4.0.0:
|
nanoid@^4.0.0:
|
||||||
version "4.0.2"
|
version "4.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
|
||||||
|
@ -5199,6 +5256,11 @@ parse-ms@^2.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
|
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
|
||||||
integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==
|
integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==
|
||||||
|
|
||||||
|
parse-srcset@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
|
||||||
|
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
|
||||||
|
|
||||||
parseurl@~1.3.3:
|
parseurl@~1.3.3:
|
||||||
version "1.3.3"
|
version "1.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||||
|
@ -5353,6 +5415,15 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
|
||||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
|
postcss@^8.3.11:
|
||||||
|
version "8.4.38"
|
||||||
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
|
||||||
|
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
|
||||||
|
dependencies:
|
||||||
|
nanoid "^3.3.7"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
source-map-js "^1.2.0"
|
||||||
|
|
||||||
postcss@^8.4.12, postcss@^8.4.18:
|
postcss@^8.4.12, postcss@^8.4.18:
|
||||||
version "8.4.31"
|
version "8.4.31"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
||||||
|
@ -5863,6 +5934,18 @@ safe-regex-test@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||||
|
|
||||||
|
sanitize-html@^2.13.0:
|
||||||
|
version "2.13.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae"
|
||||||
|
integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==
|
||||||
|
dependencies:
|
||||||
|
deepmerge "^4.2.2"
|
||||||
|
escape-string-regexp "^4.0.0"
|
||||||
|
htmlparser2 "^8.0.0"
|
||||||
|
is-plain-object "^5.0.0"
|
||||||
|
parse-srcset "^1.0.2"
|
||||||
|
postcss "^8.3.11"
|
||||||
|
|
||||||
scheduler@^0.23.0:
|
scheduler@^0.23.0:
|
||||||
version "0.23.0"
|
version "0.23.0"
|
||||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||||
|
@ -6058,6 +6141,11 @@ source-map-js@^1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
|
source-map-js@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
|
||||||
|
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
|
||||||
|
|
||||||
source-map-loader@^4.0.1:
|
source-map-loader@^4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"
|
resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"
|
||||||
|
|
Loading…
Reference in a new issue