mirror of
1
Fork 0

[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:
tobi 2024-06-18 18:18:00 +02:00 committed by GitHub
parent b08c1bd0cb
commit d2b3d37724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1389 additions and 726 deletions

View File

@ -20,12 +20,14 @@ Instance moderation settings.
### 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).
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.
### Accounts

View File

@ -4525,6 +4525,8 @@ paths:
- default: 50
description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
in: query
maximum: 200
minimum: 0
name: limit
type: integer
- description: |-
@ -5739,21 +5741,23 @@ paths:
in: query
name: target_account_id
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
name: max_id
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
name: since_id
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
name: min_id
type: string
- 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
maximum: 100
minimum: 1
name: limit
type: integer
produces:
@ -7707,21 +7711,23 @@ paths:
in: query
name: target_account_id
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
name: max_id
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
name: since_id
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
name: min_id
type: string
- 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
maximum: 100
minimum: 1
name: limit
type: integer
produces:

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

View File

@ -116,10 +116,9 @@ func (m *Module) AccountActionPOSTHandler(c *gin.Context) {
return
}
targetAcctID := c.Param(IDKey)
if targetAcctID == "" {
err := errors.New("no account id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form.TargetID = targetAcctID

View File

@ -22,6 +22,7 @@ import (
"codeberg.org/gruf/go-debug"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
@ -29,48 +30,40 @@ import (
const (
BasePath = "/v1/admin"
EmojiPath = BasePath + "/custom_emojis"
EmojiPathWithID = EmojiPath + "/:" + IDKey
EmojiPathWithID = EmojiPath + "/:" + apiutil.IDKey
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
DomainBlocksPathWithID = DomainBlocksPath + "/:" + apiutil.IDKey
DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey
DomainAllowsPathWithID = DomainAllowsPath + "/:" + apiutil.IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
HeaderAllowsPath = BasePath + "/header_allows"
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + IDKey
HeaderAllowsPathWithID = HeaderAllowsPath + "/:" + apiutil.IDKey
HeaderBlocksPath = BasePath + "/header_blocks"
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + IDKey
HeaderBlocksPathWithID = HeaderBlocksPath + "/:" + apiutil.IDKey
AccountsV1Path = BasePath + "/accounts"
AccountsV2Path = "/v2/admin/accounts"
AccountsPathWithID = AccountsV1Path + "/:" + IDKey
AccountsPathWithID = AccountsV1Path + "/:" + apiutil.IDKey
AccountsActionPath = AccountsPathWithID + "/action"
AccountsApprovePath = AccountsPathWithID + "/approve"
AccountsRejectPath = AccountsPathWithID + "/reject"
MediaCleanupPath = BasePath + "/media_cleanup"
MediaRefetchPath = BasePath + "/media_refetch"
ReportsPath = BasePath + "/reports"
ReportsPathWithID = ReportsPath + "/:" + IDKey
ReportsPathWithID = ReportsPath + "/:" + apiutil.IDKey
ReportsResolvePath = ReportsPathWithID + "/resolve"
EmailPath = BasePath + "/email"
EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + IDKey
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
DebugPath = BasePath + "/debug"
DebugAPUrlPath = DebugPath + "/apurl"
DebugClearCachesPath = DebugPath + "/caches/clear"
IDKey = "id"
FilterQueryKey = "filter"
MaxShortcodeDomainKey = "max_shortcode_domain"
MinShortcodeDomainKey = "min_shortcode_domain"
LimitKey = "limit"
DomainQueryKey = "domain"
ResolvedKey = "resolved"
AccountIDKey = "account_id"
TargetAccountIDKey = "target_account_id"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
)
type Module struct {

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -97,10 +96,9 @@ func (m *Module) EmojiDELETEHandler(c *gin.Context) {
return
}
emojiID := c.Param(IDKey)
if emojiID == "" {
err := errors.New("no emoji id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
)
@ -41,7 +42,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete1() {
path := admin.EmojiPathWithID
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.Equal(http.StatusOK, recorder.Code)
@ -78,7 +79,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDelete2() {
path := admin.EmojiPathWithID
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.Equal(http.StatusBadRequest, recorder.Code)
@ -100,7 +101,7 @@ func (suite *EmojiDeleteTestSuite) TestEmojiDeleteNotFound() {
path := admin.EmojiPathWithID
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.Equal(http.StatusNotFound, recorder.Code)

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -82,10 +81,9 @@ func (m *Module) EmojiGETHandler(c *gin.Context) {
return
}
emojiID := c.Param(IDKey)
if emojiID == "" {
err := errors.New("no emoji id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -27,6 +27,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
)
type EmojiGetTestSuite struct {
@ -39,7 +40,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet1() {
path := admin.EmojiPathWithID
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.Equal(http.StatusOK, recorder.Code)
@ -71,7 +72,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGet2() {
path := admin.EmojiPathWithID
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.Equal(http.StatusOK, recorder.Code)
@ -102,7 +103,7 @@ func (suite *EmojiGetTestSuite) TestEmojiGetNotFound() {
path := admin.EmojiPathWithID
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.Equal(http.StatusNotFound, recorder.Code)

View File

@ -20,7 +20,6 @@ package admin
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@ -76,6 +75,8 @@ import (
// type: integer
// description: Number of emojis to return. Less than 1, or not set, means unlimited (all emojis).
// default: 50
// minimum: 0
// maximum: 200
// in: query
// -
// name: max_shortcode_domain
@ -142,19 +143,10 @@ func (m *Module) EmojisGETHandler(c *gin.Context) {
maxShortcodeDomain := c.Query(MaxShortcodeDomainKey)
minShortcodeDomain := c.Query(MinShortcodeDomainKey)
limit := 50
limitString := c.Query(LimitKey)
if limitString != "" {
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
}
limit = int(i)
}
if limit < 0 {
limit = 0
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 50, 200, 0)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
var domain string

View File

@ -147,10 +147,9 @@ func (m *Module) EmojiPATCHHandler(c *gin.Context) {
return
}
emojiID := c.Param(IDKey)
if emojiID == "" {
err := errors.New("no emoji id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
emojiID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
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/testrig"
)
@ -53,7 +54,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateNewCategory() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -130,7 +131,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateSwitchCategory() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -208,7 +209,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyRemoteToLocal() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -284,7 +285,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableEmoji() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -325,7 +326,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateDisableLocalEmoji() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -358,7 +359,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyRemoteEmoji() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -391,7 +392,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateModifyNoParams() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -425,7 +426,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyLocalToLocal() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -459,7 +460,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyEmptyShortcode() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -492,7 +493,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyNoShortcode() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)
@ -526,7 +527,7 @@ func (suite *EmojiUpdateTestSuite) TestEmojiUpdateCopyShortcodeAlreadyInUse() {
bodyBytes := requestBody.Bytes()
recorder := httptest.NewRecorder()
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
suite.adminModule.EmojiPATCHHandler(ctx)

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -85,10 +84,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
return
}
reportID := c.Param(IDKey)
if reportID == "" {
err := errors.New("no report id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -107,10 +106,9 @@ func (m *Module) ReportResolvePOSTHandler(c *gin.Context) {
return
}
reportID := c.Param(IDKey)
if reportID == "" {
err := errors.New("no report id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
reportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -65,7 +66,7 @@ func (suite *ReportResolveTestSuite) resolveReport(
// create the request
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")
if actionTakenComment != nil {
ctx.Request.Form = url.Values{"action_taken_comment": {*actionTakenComment}}

View File

@ -20,12 +20,12 @@ package admin
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// ReportsGETHandler swagger:operation GET /api/v1/admin/reports adminReports
@ -72,7 +72,7 @@ import (
// name: max_id
// type: string
// 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.
// in: query
// -
@ -81,23 +81,21 @@ import (
// 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.
// in: query
// -
// name: min_id
// type: string
// 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.
// This parameter is functionally equivalent to since_id.
// in: query
// -
// name: limit
// type: integer
// 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.
// default: 20
// minimum: 1
// maximum: 100
// in: query
//
// security:
@ -144,34 +142,30 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
return
}
var resolved *bool
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
i, err := strconv.ParseBool(resolvedString)
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
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
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
}
// normalize
if i < 1 || i > 100 {
i = 100
}
limit = i
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
100, // max limit
20, // default limit
)
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 {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View File

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/admin"
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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -63,24 +64,24 @@ func (suite *ReportsGetTestSuite) getReports(
ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI
requestPath := admin.ReportsPath + "?" + admin.LimitKey + "=" + strconv.Itoa(limit)
requestPath := admin.ReportsPath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
if resolved != nil {
requestPath = requestPath + "&" + admin.ResolvedKey + "=" + strconv.FormatBool(*resolved)
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
}
if accountID != "" {
requestPath = requestPath + "&" + admin.AccountIDKey + "=" + accountID
requestPath = requestPath + "&" + apiutil.AccountIDKey + "=" + accountID
}
if targetAccountID != "" {
requestPath = requestPath + "&" + admin.TargetAccountIDKey + "=" + targetAccountID
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
}
if maxID != "" {
requestPath = requestPath + "&" + admin.MaxIDKey + "=" + maxID
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
}
if sinceID != "" {
requestPath = requestPath + "&" + admin.SinceIDKey + "=" + sinceID
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
}
if minID != "" {
requestPath = requestPath + "&" + admin.MinIDKey + "=" + minID
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
}
baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath
@ -766,7 +767,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
}
]`, 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() {
@ -1028,8 +1029,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetZeroLimit() {
suite.NoError(err)
suite.Len(reports, 2)
// Limit in Link header should be set to 100
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)
// Limit in Link header should be set to default (20)
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() {

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -95,10 +94,9 @@ func (m *Module) RuleDELETEHandler(c *gin.Context) {
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -85,10 +84,9 @@ func (m *Module) RuleGETHandler(c *gin.Context) {
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -18,7 +18,6 @@
package admin
import (
"errors"
"fmt"
"net/http"
@ -87,10 +86,9 @@ func (m *Module) RulePATCHHandler(c *gin.Context) {
return
}
ruleID := c.Param(IDKey)
if ruleID == "" {
err := errors.New("no rule id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
ruleID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -18,7 +18,6 @@
package reports
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@ -77,10 +76,9 @@ func (m *Module) ReportGETHandler(c *gin.Context) {
return
}
targetReportID := c.Param(IDKey)
if targetReportID == "" {
err := errors.New("no report id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
targetReportID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}

View File

@ -145,7 +145,7 @@ func (suite *ReportGetTestSuite) TestGetReport2() {
}
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.Nil(report)
}

View File

@ -21,19 +21,13 @@ import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/processing"
)
const (
BasePath = "/v1/reports"
IDKey = "id"
ResolvedKey = "resolved"
TargetAccountIDKey = "target_account_id"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
LimitKey = "limit"
BasePathWithID = BasePath + "/:" + IDKey
BasePath = "/v1/reports"
BasePathWithID = BasePath + "/:" + apiutil.IDKey
)
type Module struct {

View File

@ -18,14 +18,13 @@
package reports
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// ReportsGETHandler swagger:operation GET /api/v1/reports reports
@ -67,7 +66,7 @@ import (
// name: max_id
// type: string
// 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.
// in: query
// -
@ -76,24 +75,21 @@ import (
// 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.
// in: query
// -
// name: min_id
// type: string
// 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.
// This parameter is functionally equivalent to since_id.
// in: query
// -
// name: limit
// type: integer
// 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.
// default: 20
// minimum: 1
// maximum: 100
// in: query
//
// security:
@ -134,36 +130,29 @@ func (m *Module) ReportsGETHandler(c *gin.Context) {
return
}
var resolved *bool
if resolvedString := c.Query(ResolvedKey); resolvedString != "" {
i, err := strconv.ParseBool(resolvedString)
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
resolved, errWithCode := apiutil.ParseResolved(c.Query(apiutil.ResolvedKey), nil)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
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
}
// normalize
if i <= 0 {
i = 1
} else if i >= 100 {
i = 100
}
limit = i
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
100, // max limit
20, // default limit
)
if errWithCode != nil {
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 {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return

View File

@ -29,6 +29,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/reports"
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/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -61,21 +62,21 @@ func (suite *ReportsGetTestSuite) getReports(
ctx.Set(oauth.SessionAuthorizedUser, user)
// create the request URI
requestPath := reports.BasePath + "?" + reports.LimitKey + "=" + strconv.Itoa(limit)
requestPath := reports.BasePath + "?" + apiutil.LimitKey + "=" + strconv.Itoa(limit)
if resolved != nil {
requestPath = requestPath + "&" + reports.ResolvedKey + "=" + strconv.FormatBool(*resolved)
requestPath = requestPath + "&" + apiutil.ResolvedKey + "=" + strconv.FormatBool(*resolved)
}
if targetAccountID != "" {
requestPath = requestPath + "&" + reports.TargetAccountIDKey + "=" + targetAccountID
requestPath = requestPath + "&" + apiutil.TargetAccountIDKey + "=" + targetAccountID
}
if maxID != "" {
requestPath = requestPath + "&" + reports.MaxIDKey + "=" + maxID
requestPath = requestPath + "&" + apiutil.MaxIDKey + "=" + maxID
}
if sinceID != "" {
requestPath = requestPath + "&" + reports.SinceIDKey + "=" + sinceID
requestPath = requestPath + "&" + apiutil.SinceIDKey + "=" + sinceID
}
if minID != "" {
requestPath = requestPath + "&" + reports.MinIDKey + "=" + minID
requestPath = requestPath + "&" + apiutil.MinIDKey + "=" + minID
}
baseURI := config.GetProtocol() + "://" + config.GetHost()
requestURI := baseURI + "/api/" + requestPath

View File

@ -247,7 +247,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) {
Resolve: resolve,
Following: following,
ExcludeUnreviewed: excludeUnreviewed,
AccountID: c.Query(apiutil.SearchAccountIDKey),
AccountID: c.Query(apiutil.AccountIDKey),
APIv1: apiVersion == apiutil.APIv1,
}

View File

@ -105,7 +105,7 @@ func (suite *SearchGetTestSuite) getSearch(
}
if fromAccountID != nil {
queryParts = append(queryParts, apiutil.SearchAccountIDKey+"="+url.QueryEscape(*fromAccountID))
queryParts = append(queryParts, apiutil.AccountIDKey+"="+url.QueryEscape(*fromAccountID))
}
requestURL.RawQuery = strings.Join(queryParts, "&")

View File

@ -34,13 +34,16 @@ const (
/* Common keys */
IDKey = "id"
LimitKey = "limit"
LocalKey = "local"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
UsernameKey = "username"
IDKey = "id"
LimitKey = "limit"
LocalKey = "local"
MaxIDKey = "max_id"
SinceIDKey = "since_id"
MinIDKey = "min_id"
UsernameKey = "username"
AccountIDKey = "account_id"
TargetAccountIDKey = "target_account_id"
ResolvedKey = "resolved"
/* AP endpoint keys */
@ -55,7 +58,6 @@ const (
SearchQueryKey = "q"
SearchResolveKey = "resolve"
SearchTypeKey = "type"
SearchAccountIDKey = "account_id"
/* Tag keys */
@ -132,6 +134,10 @@ func ParseLocal(value string, defaultValue bool) (bool, gtserror.WithCode) {
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) {
return parseBool(value, defaultValue, SearchExcludeUnreviewedKey)
}
@ -289,6 +295,19 @@ func parseBool(value string, defaultValue bool, key string) (bool, gtserror.With
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) {
if value == "" {
return defaultValue, nil

View File

@ -20,6 +20,7 @@ package bundb
import (
"context"
"errors"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -27,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/uptrace/bun"
)
@ -51,14 +53,23 @@ func (r *reportDB) GetReportByID(ctx context.Context, id string) (*gtsmodel.Repo
)
}
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Report, error) {
reportIDs := []string{}
func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID string, targetAccountID string, page *paging.Page) ([]*gtsmodel.Report, error) {
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.
NewSelect().
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
Column("report.id").
Order("report.id DESC")
// Select only IDs from table.
Column("report.id")
if resolved != nil {
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)
}
// Return only reports with id
// lower than provided maxID.
if maxID != "" {
q = q.Where("? < ?", bun.Ident("report.id"), maxID)
}
if sinceID != "" {
q = q.Where("? > ?", bun.Ident("report.id"), minID)
}
// Return only reports with id
// greater than provided minID.
if minID != "" {
q = q.Where("? > ?", bun.Ident("report.id"), minID)
}
if limit != 0 {
if limit > 0 {
// Limit amount of
// reports returned.
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 {
return nil, err
}
@ -102,6 +123,12 @@ func (r *reportDB) GetReports(ctx context.Context, resolved *bool, accountID str
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)
reports := make([]*gtsmodel.Report, 0, len(reportIDs))
for _, id := range reportIDs {

View File

@ -24,6 +24,8 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"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/testrig"
)
@ -61,14 +63,109 @@ func (suite *ReportTestSuite) TestGetReportByURI() {
}
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.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() {
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.NotEmpty(reports)
for _, r := range reports {

View File

@ -21,6 +21,7 @@ import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// 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.
// 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(ctx context.Context, report *gtsmodel.Report) error

View File

@ -21,73 +21,81 @@ import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
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/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"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(
ctx context.Context,
account *gtsmodel.Account,
resolved *bool,
accountID string,
targetAccountID string,
maxID string,
sinceID string,
minID string,
limit int,
page *paging.Page,
) (*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) {
return nil, gtserror.NewErrorInternalError(err)
}
count := len(reports)
if count == 0 {
return util.EmptyPageableResponse(), nil
return paging.EmptyResponse(), nil
}
var (
items = make([]interface{}, 0, count)
nextMaxIDValue = reports[count-1].ID
prevMinIDValue = reports[0].ID
)
// Get the lowest and highest
// ID values, used for paging.
lo := reports[count-1].ID
hi := reports[0].ID
// Convert each report to API model.
items := make([]interface{}, 0, count)
for _, r := range reports {
item, err := p.converter.ReportToAdminAPIReport(ctx, r, account)
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)
}
extraQueryParams := make([]string, 0, 3)
// Assemble next/prev page queries.
query := make(url.Values, 3)
if resolved != nil {
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
}
if accountID != "" {
extraQueryParams = append(extraQueryParams, "account_id="+accountID)
query.Set(apiutil.AccountIDKey, accountID)
}
if targetAccountID != "" {
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/admin/reports",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: extraQueryParams,
})
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/reports",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
// ReportGet returns one report, with the given ID.

View File

@ -21,13 +21,15 @@ import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
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/gtserror"
"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.
@ -53,53 +55,61 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin
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(
ctx context.Context,
account *gtsmodel.Account,
resolved *bool,
targetAccountID string,
maxID string,
sinceID string,
minID string,
limit int,
page *paging.Page,
) (*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) {
return nil, gtserror.NewErrorInternalError(err)
}
count := len(reports)
if count == 0 {
return util.EmptyPageableResponse(), nil
return paging.EmptyResponse(), nil
}
items := make([]interface{}, 0, count)
nextMaxIDValue := reports[count-1].ID
prevMinIDValue := reports[0].ID
// Get the lowest and highest
// ID values, used for paging.
lo := reports[count-1].ID
hi := reports[0].ID
// Convert each report to API model.
items := make([]interface{}, 0, count)
for _, r := range reports {
item, err := p.converter.ReportToAPIReport(ctx, r)
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)
}
extraQueryParams := []string{}
// Assemble next/prev page queries.
query := make(url.Values, 3)
if resolved != nil {
extraQueryParams = append(extraQueryParams, "resolved="+strconv.FormatBool(*resolved))
query.Set(apiutil.ResolvedKey, strconv.FormatBool(*resolved))
}
if targetAccountID != "" {
extraQueryParams = append(extraQueryParams, "target_account_id="+targetAccountID)
query.Set(apiutil.TargetAccountIDKey, targetAccountID)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/reports",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: extraQueryParams,
})
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/reports",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}

View File

@ -33,6 +33,7 @@
"react-redux": "^8.1.3",
"redux": "^4.2.0",
"redux-persist": "^6.0.0",
"sanitize-html": "^2.13.0",
"skulk": "^0.0.8-fix",
"wouter": "^3.1.0"
},
@ -49,6 +50,7 @@
"@types/parse-link-header": "^2.0.3",
"@types/psl": "^1.1.1",
"@types/react-dom": "^18.2.8",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",

View File

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

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

View File

@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }:
);
if (linkTo) {
className += " spanlink";
className += " pseudolink";
return (
<span
className={className}

View File

@ -21,29 +21,51 @@ import { gtsApi } from "../../gts-api";
import type {
AdminReport,
AdminReportListParams,
AdminSearchReportParams,
AdminReportResolveParams,
AdminSearchReportResp,
} from "../../../types/report";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
listReports: build.query<AdminReport[], AdminReportListParams | void>({
query: (params) => ({
url: "/api/v1/admin/reports",
params: {
// Override provided limit.
limit: 100,
...params
searchReports: build.query<AdminSearchReportResp, AdminSearchReportParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
}),
providesTags: [{ type: "Reports", id: "LIST" }]
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>({
query: (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>({
@ -55,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
}),
invalidatesTags: (res) =>
res
? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }]
: [{ type: "Reports", id: "LIST" }]
? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
: [{ type: "Report", id: "LIST" }]
})
})
});
@ -64,7 +86,7 @@ const extended = gtsApi.injectEndpoints({
/**
* 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.
@ -77,7 +99,7 @@ const useGetReportQuery = extended.useGetReportQuery;
const useResolveReportMutation = extended.useResolveReportMutation;
export {
useListReportsQuery,
useLazySearchReportsQuery,
useGetReportQuery,
useResolveReportMutation,
};

View File

@ -136,7 +136,7 @@ export const gtsApi = createApi({
tagTypes: [
"Auth",
"Emoji",
"Reports",
"Report",
"Account",
"InstanceRules",
"HTTPHeaderAllows",

View File

@ -17,6 +17,10 @@
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
* model, which contains less detailed information.
@ -56,29 +60,25 @@ export interface AdminReport {
updated_at: string;
/**
* Account that created the report.
* TODO: model this properly.
*/
account: Object;
account: AdminAccount;
/**
* Reported account.
* TODO: model this properly.
*/
target_account: Object;
target_account: AdminAccount;
/**
* 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.
* TODO: model this properly.
*/
action_taken_by_account?: Object;
action_taken_by_account?: AdminAccount;
/**
* Statuses cited by this report, if any.
* TODO: model this properly.
*/
statuses: Object[];
statuses: Status[];
/**
* Rules broken according to the reporter, if any.
* TODO: model this properly.
@ -108,7 +108,7 @@ export interface AdminReportResolveParams {
/**
* 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.
*/
@ -142,3 +142,8 @@ export interface AdminReportListParams {
*/
limit?: number;
}
export interface AdminSearchReportResp {
accounts: AdminReport[];
links: Links | null;
}

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

View File

@ -19,8 +19,8 @@
import { useMemo } from "react";
import { AdminAccount } from "../../../../lib/types/account";
import { store } from "../../../../redux/store";
import { AdminAccount } from "../types/account";
import { store } from "../../redux/store";
export function yesOrNo(b: boolean): string {
return b ? "yes" : "no";

View File

@ -1045,62 +1045,62 @@ button.with-padding {
}
}
.reports {
p {
margin: 0;
}
.reports-view {
.report {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5rem;
margin: 0.5rem 0;
text-decoration: none;
color: $fg;
padding: 1rem;
border: none;
border-left: 0.3rem solid $border-accent;
.usernames {
line-height: 2rem;
}
.username-lozenge {
display: flex;
flex-wrap: nowrap;
height: 100%;
align-items: center;
padding-top: 0;
padding-bottom: 0;
.byline {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
.report-status {
color: $border-accent;
.fa {
flex-shrink: 0;
}
}
.details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.5rem;
padding: 0.5rem;
justify-items: start;
.report-byline {
max-width: fit-content;
}
h3 {
margin: 0;
.info-list {
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 {
color: $fg-reduced;
border-left: 0.4rem solid $bg;
border-left: 0.3rem solid $list-entry-bg;
.byline .report-status {
.info-list,
.info-list .info-list-entry .reported-by .username-lozenge {
color: $fg-reduced;
}
.user {
opacity: 0.8;
&:hover {
border-color: $fg-accent;
}
}
@ -1109,70 +1109,40 @@ button.with-padding {
padding: 0;
}
}
}
.report.detail {
display: flex;
flex-direction: column;
margin-top: 1rem;
gap: 1rem;
.report-detail {
.info-list {
.info-block {
padding: 0.5rem;
background: $gray2;
&.overview {
margin-top: 1rem;
}
.info {
display: block;
}
.username-lozenge {
display: flex;
flex-wrap: nowrap;
height: 100%;
align-items: center;
padding-top: 0;
padding-bottom: 0;
max-width: fit-content;
.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;
.fa {
flex-shrink: 0;
}
}
}
}
.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;
.report-statuses {
width: min(100%, 50rem);
.acct {
word-break: break-all;
.thread {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 0;
}
}
&.suspended {
background: $bg-accent;
color: $fg;
text-decoration: line-through;
}
&.local {
background: $green1;
}
}
.spanlink {
cursor: pointer;
text-decoration: none;
}
.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 {
border: 0.1rem solid $gray1;
display: flex;

View File

@ -22,7 +22,7 @@ import { Redirect, useParams } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
import useFormSubmit from "../../../../lib/form/submit";
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 Loading from "../../../../components/loading";
import { FileInput } from "../../../../components/form/inputs";
@ -124,14 +124,14 @@ function EmojiDetailForm({ data: emoji }) {
disabled={!form.image.value}
/>
<FakeToot>
<FakeStatus>
Look at this new custom emoji <img
className="emoji"
src={form.image.previewValue ?? emoji.url}
title={`:${emoji.shortcode}:`}
alt={emoji.shortcode}
/> isn&apos;t it cool?
</FakeToot>
</FakeStatus>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}

View File

@ -23,7 +23,7 @@ import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../../lib/form/submit";
import { TextInput, FileInput } from "../../../../components/form/inputs";
import { CategorySelect } from '../category-select';
import FakeToot from "../../../../components/fake-toot";
import { FakeStatus } from "../../../../components/status";
import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
@ -103,9 +103,9 @@ export default function NewEmojiForm() {
<div>
<h2>Add new custom emoji</h2>
<FakeToot>
<FakeStatus>
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
</FakeStatus>
<form onSubmit={submitForm} className="form-flex">
<FileInput

View File

@ -69,7 +69,7 @@ export default function HeaderPermsOverview() {
return (
<dl
key={perm.id}
className="entry spanlink"
className="entry pseudolink"
onClick={() => {
// When clicking on a header perm,
// go to the detail view for perm.

View File

@ -21,13 +21,13 @@ import React from "react";
import { useGetAccountQuery } from "../../../../lib/query/admin";
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 { AccountActions } from "./actions";
import { useParams } from "wouter";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import { UseOurInstanceAccount, yesOrNo } from "./util";
import { UseOurInstanceAccount, yesOrNo } from "../../../../lib/util";
export default function AccountDetail() {
const params: { accountID: string } = useParams();

View File

@ -83,7 +83,7 @@ export function AccountSearchForm() {
}
// 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 itemToEntry(account: AdminAccount): ReactNode {

View File

@ -17,8 +17,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useState } from "react";
import { useParams } from "wouter";
import React from "react";
import { useLocation, useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
import { useValue, useTextInput } from "../../../lib/form";
@ -28,84 +28,172 @@ import MutationButton from "../../../components/form/mutation-button";
import Username from "../../../components/username";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
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({ }) {
const params: { reportId: string } = useParams();
const baseUrl = useBaseUrl();
const params = useParams();
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
return (
<div className="reports">
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
<div className="report-detail">
<h1><BackButton to={backLocation}/> Report Details</h1>
<FormWithData
dataQuery={useGetReportQuery}
queryArg={params.reportId}
DataForm={ReportDetailForm}
{...{ backLocation: backLocation }}
/>
</div>
);
}
function ReportDetailForm({ data: report }) {
const from = report.account;
const target = report.target_account;
function ReportDetailForm({ data: report }: { data: AdminReport }) {
const [ location ] = useLocation();
const baseUrl = useBaseUrl();
return (
<div className="report detail">
<div className="usernames">
<Username
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~/settings/moderation/reports/${report.id}`}
/>
<> reported </>
<Username
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~/settings/moderation/reports/${report.id}`}
/>
</div>
<>
<ReportBasicInfo
report={report}
baseUrl={baseUrl}
location={location}
/>
{report.action_taken &&
<div className="info">
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
<br />
<b>Comment: </b><span>{report.action_taken_comment}</span>
</div>
{ report.action_taken
&& <ReportHistory
report={report}
baseUrl={baseUrl}
location={location}
/>
}
<div className="info-block">
<h3>Report info:</h3>
<div className="details">
<b>Created: </b>
<span>{new Date(report.created_at).toLocaleString()}</span>
{ report.statuses &&
<ReportStatuses report={report} />
}
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
<b>Category: </b> <span>{report.category}</span>
{ !report.action_taken &&
<ReportActionForm report={report} />
}
</>
);
}
<b>Reason: </b>
{report.comment.length > 0
? <p>{report.comment}</p>
: <i className="no-comment">none provided</i>
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
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Reported by</dt>
<dd>
<Username
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Status</dt>
<dd>
{ report.action_taken
? <>{status}</>
: <b>{status}</b>
}
</div>
</dd>
</div>
{!report.action_taken && <ReportActionForm report={report} />}
<div className="info-list-entry">
<dt>Reason</dt>
<dd>
{ comment.length > 0
? <>{comment}</>
: <i>none provided</i>
}
</dd>
</div>
{
report.statuses.length > 0 &&
<div className="info-block">
<h3>Reported toots ({report.statuses.length}):</h3>
<div className="reported-toots">
{report.statuses.map((status) => (
<ReportedToot key={status.id} toot={status} />
))}
</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>
);
}
function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
const handled_by = report.action_taken_by_account;
if (!handled_by) {
throw "report handled by action_taken_by_account undefined";
}
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>
<div className="info-list-entry">
<dt>Handled</dt>
<dd>
<time dateTime={report.action_taken_at}>{handled}</time>
</dd>
</div>
<div className="info-list-entry">
<dt>Comment</dt>
<dd>{ report.action_taken_comment ?? "none"}</dd>
</div>
</dl>
</>
);
}
@ -118,13 +206,18 @@ function ReportActionForm({ report }) {
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
return (
<form onSubmit={submit} className="info-block">
<h3>Resolving this report</h3>
<p>
<form onSubmit={submit}>
<h3>Resolve this report</h3>
<>
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 />
<b>This will be visible to the user that created the report!</b>
</p>
This is useful for providing an explanation about what action was
taken (if any) before the report was marked as resolved.
<br />
<b>
Any comment made here will be visible
to the user that created the report!
</b>
</>
<TextArea
field={form.comment}
label="Comment"
@ -138,116 +231,24 @@ function ReportActionForm({ report }) {
);
}
function ReportedToot({ toot }) {
const account = toot.account;
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);
function ReportStatuses({ report }: { report: AdminReport }) {
if (report.statuses.length === 0) {
return null;
}
return (
<>
<div className="spoiler">
<span>{note}</span>
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
</div>
{visible && content}
</>
);
}
function TootMedia({ media, sensitive }) {
let classes = (media.length % 2 == 0) ? "even" : "odd";
if (media.length == 1) {
classes += " single";
}
return (
<div className={`media photoswipe-gallery ${classes}`}>
{media.map((m) => (
<div key={m.id} className="media-wrapper">
{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>
))}
<div className="report-statuses">
<h3>Reported Statuses</h3>
<ul className="thread">
{ report.statuses.map((status) => {
return (
<Status
key={status.id}
status={status}
/>
);
})}
</ul>
</div>
);
}

View File

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

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

View File

@ -20,7 +20,7 @@
import React from "react";
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import { ReportOverview } from "./reports/overview";
import ReportsSearch from "./reports/search";
import ReportDetail from "./reports/detail";
import { ErrorBoundary } from "../../lib/navigation/error";
import ImportExport from "./domain-permissions/import-export";
@ -85,8 +85,9 @@ function ModerationReportsRouter() {
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/search" component={ReportsSearch}/>
<Route path={"/:reportId"} component={ReportDetail} />
<Route component={ReportOverview}/>
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>

View File

@ -39,7 +39,7 @@ import {
} from "../../components/form/inputs";
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 { useAccountThemesQuery } from "../../lib/query/user";

View File

@ -1499,6 +1499,13 @@
"@types/scheduler" "*"
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@*":
version "0.16.4"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf"
@ -3125,11 +3132,41 @@ doctrine@^3.0.0:
dependencies:
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:
version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
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:
version "1.1.1"
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"
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:
version "1.3.2"
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"
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:
version "2.0.0"
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"
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:
version "4.0.2"
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"
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:
version "1.3.3"
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"
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:
version "8.4.31"
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"
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:
version "0.23.0"
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"
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:
version "4.0.1"
resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"