[feature] Show info for pending replies, allow implicit accept of pending replies

This commit is contained in:
tobi 2024-09-19 18:16:01 +02:00
parent 2f56455eed
commit 24ddf90a7e
11 changed files with 393 additions and 36 deletions

View file

@ -223,7 +223,7 @@ func NewProcessor(
processor.tags = tags.New(state, converter)
processor.timeline = timeline.New(state, converter, visFilter)
processor.search = search.New(state, federator, converter, visFilter)
processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc)
processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc)
processor.user = user.New(state, converter, oauthServer, emailSender)
// The advanced migrations processor sequences advanced migrations from all other processors.

View file

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// BoostCreate processes the boost/reblog of target
@ -138,6 +139,23 @@ func (p *Processor) BoostCreate(
Target: target.Account,
})
// If the boost target status replies to a status
// that we own, and has a pending interaction
// request, use the boost as an implicit accept.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, target,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// target status as no longer pending approval so
// it's serialized properly via the API.
if implicitlyAccepted {
target.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, boost)
}

View file

@ -164,6 +164,23 @@ func (p *Processor) Create(
}
}
// If the new status replies to a status that
// replies to us, use our reply as an implicit
// accept of any pending interaction.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, status,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// replied-to status as no longer pending approval
// so it's serialized properly via the API.
if implicitlyAccepted {
status.InReplyTo.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, status)
}

View file

@ -31,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) getFaveableStatus(
@ -138,8 +139,6 @@ func (p *Processor) FaveCreate(
pendingApproval = false
}
status.PendingApproval = &pendingApproval
// Create a new fave, marking it
// as pending approval if necessary.
faveID := id.NewULID()
@ -157,7 +156,7 @@ func (p *Processor) FaveCreate(
}
if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil {
err = fmt.Errorf("FaveCreate: error putting fave in database: %w", err)
err = gtserror.Newf("db error putting fave: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
@ -170,6 +169,23 @@ func (p *Processor) FaveCreate(
Target: status.Account,
})
// If the fave target status replies to a status
// that we own, and has a pending interaction
// request, use the fave as an implicit accept.
implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
requester, status,
)
if errWithCode != nil {
return nil, errWithCode
}
// If we ended up implicitly accepting, mark the
// target status as no longer pending approval so
// it's serialized properly via the API.
if implicitlyAccepted {
status.PendingApproval = util.Ptr(false)
}
return p.c.GetAPIStatus(ctx, requester, status)
}

View file

@ -23,6 +23,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/text"
@ -42,7 +43,8 @@ type Processor struct {
parseMention gtsmodel.ParseMentionFunc
// other processors
polls *polls.Processor
polls *polls.Processor
intReqs *interactionrequests.Processor
}
// New returns a new status processor.
@ -50,6 +52,7 @@ func New(
state *state.State,
common *common.Processor,
polls *polls.Processor,
intReqs *interactionrequests.Processor,
federator *federation.Federator,
converter *typeutils.Converter,
visFilter *visibility.Filter,
@ -66,5 +69,6 @@ func New(
formatter: text.NewFormatter(state.DB),
parseMention: parseMention,
polls: polls,
intReqs: intReqs,
}
}

View file

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/state"
@ -100,11 +101,13 @@ func (suite *StatusStandardTestSuite) SetupTest() {
common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter)
polls := polls.New(&common, &suite.state, suite.typeConverter)
intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter)
suite.status = status.New(
&suite.state,
&common,
&polls,
&intReqs,
suite.federator,
suite.typeConverter,
visFilter,

View file

@ -0,0 +1,72 @@
// 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/>.
package status
import (
"context"
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) implicitlyAccept(
ctx context.Context,
requester *gtsmodel.Account,
status *gtsmodel.Status,
) (bool, gtserror.WithCode) {
if status.InReplyToAccountID != requester.ID {
// Status doesn't reply to us,
// we can't accept on behalf
// of someone else.
return false, nil
}
targetPendingApproval := util.PtrOrValue(status.PendingApproval, false)
if !targetPendingApproval {
// Status isn't pending approval,
// nothing to implicitly accept.
return false, nil
}
// Status is pending approval,
// check for an interaction request.
intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something's gone wrong.
err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err)
return false, gtserror.NewErrorInternalError(err)
}
// No interaction request present
// for this status. Race condition?
if intReq == nil {
return false, nil
}
// Accept the interaction.
if _, errWithCode := p.intReqs.Accept(ctx,
requester, intReq.ID,
); errWithCode != nil {
return false, errWithCode
}
return true, nil
}

View file

@ -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,16 @@ 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.
false, // Don't add note about pending.
)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
@ -2604,13 +2662,15 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
return nil, err
}
interactedStatus, err := c.StatusToAPIStatus(
interactedStatus, err := c.statusToAPIStatus(
ctx,
req.Status,
requestingAcct,
statusfilter.FilterContextNone,
nil,
nil,
nil, // No filters.
nil, // No mutes.
true, // Placehold unknown attachments.
false, // Don't add note about pending.
)
if err != nil {
err := gtserror.Newf("error converting interacted status: %w", err)

View file

@ -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": "<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 to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply 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 (opens in a new tab)</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)

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 to your status is pending your approval. You can accept the reply by liking, replying to, or boosting it. You can also accept or reject the reply at the following link: `)
note.WriteString(`<a href="` + settingsURL + `" `)
note.WriteString(`rel="noreferrer noopener" target="_blank">`)
note.WriteString(settingsURL + ` (opens in a new tab)`)
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

View file

@ -52,10 +52,10 @@ export default function UserRouter() {
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} />
<InteractionRequestsRouter />
<Route><Redirect to="/profile" /></Route>
</Switch>
</ErrorBoundary>
<InteractionRequestsRouter />
</Router>
</BaseUrlContext.Provider>
);
@ -73,13 +73,11 @@ function InteractionRequestsRouter() {
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</Router>
</BaseUrlContext.Provider>
);