Merge branch 'main' into profile-boosts

This commit is contained in:
Victor Dyotte 2024-09-24 15:51:41 -04:00 committed by GitHub
commit 90b773ae2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2313 additions and 954 deletions

View file

@ -473,19 +473,20 @@ const (
type TypeUtilsTestSuite struct {
suite.Suite
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatues map[string]*gtsmodel.FilterStatus
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatues map[string]*gtsmodel.FilterStatus
testInteractionRequests map[string]*gtsmodel.InteractionRequest
typeconverter *typeutils.Converter
}
@ -512,6 +513,7 @@ func (suite *TypeUtilsTestSuite) SetupTest() {
suite.testFilters = testrig.NewTestFilters()
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
suite.testFilterStatues = testrig.NewTestFilterStatuses()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
suite.typeconverter = typeutils.NewConverter(&suite.state)
testrig.StandardDBSetup(suite.db, nil)

View file

@ -803,26 +803,55 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
}, nil
}
// StatusToAPIStatus converts a gts model status into its api
// (frontend) representation for serialization on the API.
// StatusToAPIStatus converts a gts model
// status into its api (frontend) representation
// for serialization on the API.
//
// Requesting account can be nil.
//
// Filter context can be the empty string if these statuses are not being filtered.
// filterContext can be the empty string
// if these statuses are not being filtered.
//
// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error;
// callers need to handle that case by excluding it from results.
// If there is a matching "hide" filter, the returned
// status will be nil with a ErrHideStatus error; callers
// need to handle that case by excluding it from results.
func (c *Converter) StatusToAPIStatus(
ctx context.Context,
s *gtsmodel.Status,
status *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
) (*apimodel.Status, error) {
return c.statusToAPIStatus(
ctx,
status,
requestingAccount,
filterContext,
filters,
mutes,
true,
true,
)
}
// statusToAPIStatus is the package-internal implementation
// of StatusToAPIStatus that lets the caller customize whether
// to placehold unknown attachment types, and/or add a note
// about the status being pending and requiring approval.
func (c *Converter) statusToAPIStatus(
ctx context.Context,
status *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
placeholdAttachments bool,
addPendingNote bool,
) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(
ctx,
s,
status,
requestingAccount, // Can be nil.
filterContext, // Can be empty.
filters,
@ -833,7 +862,7 @@ func (c *Converter) StatusToAPIStatus(
}
// Convert author to API model.
acct, err := c.AccountToAPIAccountPublic(ctx, s.Account)
acct, err := c.AccountToAPIAccountPublic(ctx, status.Account)
if err != nil {
return nil, gtserror.Newf("error converting status acct: %w", err)
}
@ -842,23 +871,43 @@ func (c *Converter) StatusToAPIStatus(
// Convert author of boosted
// status (if set) to API model.
if apiStatus.Reblog != nil {
boostAcct, err := c.AccountToAPIAccountPublic(ctx, s.BoostOfAccount)
boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount)
if err != nil {
return nil, gtserror.Newf("error converting boost acct: %w", err)
}
apiStatus.Reblog.Account = boostAcct
}
// Normalize status for API by pruning
// attachments that were not locally
// stored, replacing them with a helpful
// message + links to remote.
var aside string
aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
apiStatus.Content += aside
if apiStatus.Reblog != nil {
aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
apiStatus.Reblog.Content += aside
if placeholdAttachments {
// Normalize status for API by pruning attachments
// that were not able to be locally stored, and replacing
// them with a helpful message + links to remote.
var attachNote string
attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
apiStatus.Content += attachNote
// Do the same for the reblogged status.
if apiStatus.Reblog != nil {
attachNote, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
apiStatus.Reblog.Content += attachNote
}
}
if addPendingNote {
// If this status is pending approval and
// replies to the requester, add a note
// about how to approve or reject the reply.
pendingApproval := util.PtrOrValue(status.PendingApproval, false)
if pendingApproval &&
requestingAccount != nil &&
requestingAccount.ID == status.InReplyToAccountID {
pendingNote, err := c.pendingReplyNote(ctx, status)
if err != nil {
return nil, gtserror.Newf("error deriving 'pending reply' note: %w", err)
}
apiStatus.Content += pendingNote
}
}
return apiStatus, nil
@ -1991,7 +2040,20 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
}
}
for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
status, err := c.statusToAPIStatus(
ctx,
s,
requestingAccount,
statusfilter.FilterContextNone,
nil, // No filters.
nil, // No mutes.
true, // Placehold unknown attachments.
// Don't add note about
// pending, it's not
// relevant here.
false,
)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
@ -2628,8 +2690,8 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
req.Status,
requestingAcct,
statusfilter.FilterContextNone,
nil,
nil,
nil, // No filters.
nil, // No mutes.
)
if err != nil {
err := gtserror.Newf("error converting interacted status: %w", err)
@ -2638,13 +2700,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
var reply *apimodel.Status
if req.InteractionType == gtsmodel.InteractionReply {
reply, err = c.StatusToAPIStatus(
reply, err = c.statusToAPIStatus(
ctx,
req.Reply,
requestingAcct,
statusfilter.FilterContextNone,
nil,
nil,
nil, // No filters.
nil, // No mutes.
true, // Placehold unknown attachments.
// Don't add note about pending;
// requester already knows it's
// pending because they're looking
// at the request right now.
false,
)
if err != nil {
err := gtserror.Newf("error converting reply: %w", err)

View file

@ -18,6 +18,7 @@
package typeutils_test
import (
"bytes"
"context"
"encoding/json"
"testing"
@ -1709,6 +1710,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() {
var (
testStatus = suite.testStatuses["admin_account_status_5"]
requestingAccount = suite.testAccounts["local_account_2"]
)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
statusfilter.FilterContextNone,
nil,
nil,
)
if err != nil {
suite.FailNow(err.Error())
}
// We want to see the HTML in
// the status so don't escape it.
out := new(bytes.Buffer)
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(apiStatus); err != nil {
suite.FailNow(err.Error())
}
suite.Equal(`{
"id": "01J5QVB9VC76NPPRQ207GG4DRZ",
"created_at": "2024-02-20T10:41:37.000Z",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\"> Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
]
},
"media_attachments": [],
"mentions": [
{
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"url": "http://localhost:8080/@1happyturtle",
"acct": "1happyturtle"
}
],
"tags": [],
"emojis": [],
"card": null,
"poll": null,
"text": "Hi @1happyturtle, can I reply?",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
}
`, out.String())
}
func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)
@ -2996,6 +3121,244 @@ func (suite *InternalToFrontendTestSuite) TestRelationshipFollowRequested() {
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() {
requestingAccount := suite.testAccounts["local_account_2"]
adminReport, err := suite.typeconverter.InteractionReqToAPIInteractionReq(
context.Background(),
suite.testInteractionRequests["admin_account_reply_turtle"],
requestingAccount,
)
if err != nil {
suite.FailNow(err.Error())
}
b, err := json.MarshalIndent(adminReport, "", " ")
if err != nil {
suite.FailNow(err.Error())
}
suite.Equal(`{
"id": "01J5QVXCCEATJYSXM9H6MZT4JR",
"type": "reply",
"created_at": "2024-02-20T10:41:37.000Z",
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
]
},
"status": {
"id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"created_at": "2021-10-20T10:40:37.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": true,
"spoiler_text": "you won't be able to reply to this without my approval",
"visibility": "unlisted",
"language": "en",
"uri": "http://localhost:8080/users/1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5",
"url": "http://localhost:8080/@1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5",
"replies_count": 1,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
"reblog": null,
"application": {
"name": "kindaweird",
"website": "https://kindaweird.app"
},
"account": {
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"acct": "1happyturtle",
"display_name": "happy little turtle :3",
"locked": true,
"discoverable": false,
"bot": false,
"created_at": "2022-06-04T13:12:00.000Z",
"note": "\u003cp\u003ei post about things that concern me\u003c/p\u003e",
"url": "http://localhost:8080/@1happyturtle",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 8,
"last_status_at": "2021-07-28T08:40:37.000Z",
"emojis": [],
"fields": [
{
"name": "should you follow me?",
"value": "maybe!",
"verified_at": null
},
{
"name": "age",
"value": "120",
"verified_at": null
}
],
"hide_collections": true
},
"media_attachments": [],
"mentions": [],
"tags": [],
"emojis": [],
"card": null,
"poll": null,
"text": "🐢 i don't mind people sharing and liking this one but I want to moderate replies to it 🐢",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"author",
"me"
],
"with_approval": [
"public"
]
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
},
"reply": {
"id": "01J5QVB9VC76NPPRQ207GG4DRZ",
"created_at": "2024-02-20T10:41:37.000Z",
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"sensitive": false,
"spoiler_text": "",
"visibility": "unlisted",
"language": null,
"uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
"replies_count": 0,
"reblogs_count": 0,
"favourites_count": 0,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": false,
"pinned": false,
"content": "\u003cp\u003eHi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003e1happyturtle\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e, can I reply?\u003c/p\u003e",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
]
},
"media_attachments": [],
"mentions": [
{
"id": "01F8MH5NBDF2MV7CTC4Q5128HF",
"username": "1happyturtle",
"url": "http://localhost:8080/@1happyturtle",
"acct": "1happyturtle"
}
],
"tags": [],
"emojis": [],
"card": null,
"poll": null,
"text": "Hi @1happyturtle, can I reply?",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
}
}`, string(b))
}
func TestInternalToFrontendTestSuite(t *testing.T) {
suite.Run(t, new(InternalToFrontendTestSuite))
}

View file

@ -19,6 +19,7 @@ package typeutils
import (
"context"
"errors"
"fmt"
"math"
"net/url"
@ -30,6 +31,8 @@ import (
"github.com/k3a/html2text"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
return text.SanitizeToHTML(note.String()), arr
}
func (c *Converter) pendingReplyNote(
ctx context.Context,
s *gtsmodel.Status,
) (string, error) {
intReq, err := c.state.DB.GetInteractionRequestByInteractionURI(ctx, s.URI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something's gone wrong.
err := gtserror.Newf("db error getting interaction request for %s: %w", s.URI, err)
return "", err
}
// No interaction request present
// for this status. Race condition?
if intReq == nil {
return "", nil
}
var (
proto = config.GetProtocol()
host = config.GetHost()
// Build the settings panel URL at which the user
// can view + approve/reject the interaction request.
//
// Eg., https://example.org/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR
settingsURL = proto + "://" + host + "/settings/user/interaction_requests/" + intReq.ID
)
var note strings.Builder
note.WriteString(`<hr>`)
note.WriteString(`<p><i lang="en"> Note from ` + host + `: `)
note.WriteString(`This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: `)
note.WriteString(`<a href="` + settingsURL + `" `)
note.WriteString(`rel="noreferrer noopener" target="_blank">`)
note.WriteString(settingsURL)
note.WriteString(`</a>.`)
note.WriteString(`</i></p>`)
return text.SanitizeToHTML(note.String()), nil
}
// ContentToContentLanguage tries to
// extract a content string and language
// tag string from the given intermediary