From 1ce854358def5f04b7c3b73418ab56bb58512634 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:42:19 +0200 Subject: [feature] Show info for pending replies, allow implicit accept of pending replies (#3322) * [feature] Allow implicit accept of pending replies * update wording --- internal/typeutils/internaltofrontend.go | 121 +++++++++++++++++++------ internal/typeutils/internaltofrontend_test.go | 125 ++++++++++++++++++++++++++ internal/typeutils/util.go | 44 +++++++++ 3 files changed, 264 insertions(+), 26 deletions(-) (limited to 'internal/typeutils') diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index fe49766fa..f36175eab 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -800,26 +800,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, @@ -830,7 +859,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) } @@ -839,23 +868,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 @@ -1972,7 +2021,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) } @@ -2609,8 +2671,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) @@ -2619,13 +2681,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, + req.Status, 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) diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index a44afe67e..dbb6d6a5d 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -18,6 +18,7 @@ package typeutils_test import ( + "bytes" "context" "encoding/json" "testing" @@ -1708,6 +1709,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": "
Hi @1happyturtle, can I reply?
ℹ️ 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: http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR.
", + "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) diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index 3a867ba35..1747dbdcd 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -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(`ℹ️ 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(``) + note.WriteString(settingsURL) + note.WriteString(`.`) + note.WriteString(`
`) + + return text.SanitizeToHTML(note.String()), nil +} + // ContentToContentLanguage tries to // extract a content string and language // tag string from the given intermediary -- cgit v1.2.3