summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-09-23 14:42:19 +0200
committerLibravatar GitHub <noreply@github.com>2024-09-23 14:42:19 +0200
commit1ce854358def5f04b7c3b73418ab56bb58512634 (patch)
tree94d827b90e435c88367d080f53b63ee2285905d6
parent[chore] add nometrics build tagging to metrics API endpoint (#3331) (diff)
downloadgotosocial-1ce854358def5f04b7c3b73418ab56bb58512634.tar.xz
[feature] Show info for pending replies, allow implicit accept of pending replies (#3322)
* [feature] Allow implicit accept of pending replies * update wording
-rw-r--r--internal/api/client/statuses/status_test.go114
-rw-r--r--internal/api/client/statuses/statusboost_test.go751
-rw-r--r--internal/api/client/statuses/statuscreate_test.go92
-rw-r--r--internal/api/client/statuses/statusfave_test.go300
-rw-r--r--internal/processing/processor.go2
-rw-r--r--internal/processing/status/boost.go18
-rw-r--r--internal/processing/status/create.go17
-rw-r--r--internal/processing/status/fave.go22
-rw-r--r--internal/processing/status/status.go6
-rw-r--r--internal/processing/status/status_test.go3
-rw-r--r--internal/processing/status/util.go72
-rw-r--r--internal/typeutils/internaltofrontend.go121
-rw-r--r--internal/typeutils/internaltofrontend_test.go125
-rw-r--r--internal/typeutils/util.go44
-rw-r--r--web/source/settings/views/user/router.tsx14
15 files changed, 1321 insertions, 380 deletions
diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go
index a979f0c00..1a92276a1 100644
--- a/internal/api/client/statuses/status_test.go
+++ b/internal/api/client/statuses/status_test.go
@@ -18,6 +18,12 @@
package statuses_test
import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http/httptest"
+ "strings"
+
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -25,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
@@ -59,6 +66,113 @@ type StatusStandardTestSuite struct {
statusModule *statuses.Module
}
+// Normalizes a status response to a determinate
+// form, and pretty-prints it to JSON.
+func (suite *StatusStandardTestSuite) parseStatusResponse(
+ recorder *httptest.ResponseRecorder,
+) (string, *httptest.ResponseRecorder) {
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ data, err := io.ReadAll(result.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ rawMap := make(map[string]any)
+ if err := json.Unmarshal(data, &rawMap); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Make status fields determinate.
+ suite.determinateStatus(rawMap)
+
+ // For readability, don't
+ // escape HTML, and indent json.
+ out := new(bytes.Buffer)
+ enc := json.NewEncoder(out)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+
+ if err := enc.Encode(&rawMap); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ return strings.TrimSpace(out.String()), recorder
+}
+
+func (suite *StatusStandardTestSuite) determinateStatus(rawMap map[string]any) {
+ // Replace any fields from the raw map that
+ // aren't determinate (date, id, url, etc).
+ if _, ok := rawMap["id"]; ok {
+ rawMap["id"] = id.Highest
+ }
+
+ if _, ok := rawMap["uri"]; ok {
+ rawMap["uri"] = "http://localhost:8080/some/determinate/url"
+ }
+
+ if _, ok := rawMap["url"]; ok {
+ rawMap["url"] = "http://localhost:8080/some/determinate/url"
+ }
+
+ if _, ok := rawMap["created_at"]; ok {
+ rawMap["created_at"] = "right the hell just now babyee"
+ }
+
+ // Make ID of any mentions determinate.
+ if menchiesRaw, ok := rawMap["mentions"]; ok {
+ menchies, ok := menchiesRaw.([]any)
+ if !ok {
+ suite.FailNow("couldn't coerce menchies")
+ }
+
+ for _, menchieRaw := range menchies {
+ menchie, ok := menchieRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce menchie")
+ }
+
+ if _, ok := menchie["id"]; ok {
+ menchie["id"] = id.Highest
+ }
+ }
+ }
+
+ // Make fields of any poll determinate.
+ if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
+ poll, ok := pollRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce poll")
+ }
+
+ if _, ok := poll["id"]; ok {
+ poll["id"] = id.Highest
+ }
+
+ if _, ok := poll["expires_at"]; ok {
+ poll["expires_at"] = "ah like you know whatever dude it's chill"
+ }
+ }
+
+ // Replace account since that's not really
+ // what we care about for these tests.
+ if _, ok := rawMap["account"]; ok {
+ rawMap["account"] = "yeah this is my account, what about it punk"
+ }
+
+ // If status contains an embedded
+ // reblog do the same thing for that.
+ if reblogRaw, ok := rawMap["reblog"]; ok && reblogRaw != nil {
+ reblog, ok := reblogRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce reblog")
+ }
+ suite.determinateStatus(reblog)
+ }
+}
+
func (suite *StatusStandardTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
diff --git a/internal/api/client/statuses/statusboost_test.go b/internal/api/client/statuses/statusboost_test.go
index f6f589a5c..8642ba7aa 100644
--- a/internal/api/client/statuses/statusboost_test.go
+++ b/internal/api/client/statuses/statusboost_test.go
@@ -17,9 +17,6 @@ package statuses_test
import (
"context"
- "encoding/json"
- "fmt"
- "io/ioutil"
"net/http"
"net/http/httptest"
"strings"
@@ -28,7 +25,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
@@ -38,212 +35,596 @@ type StatusBoostTestSuite struct {
StatusStandardTestSuite
}
-func (suite *StatusBoostTestSuite) TestPostBoost() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_1"]
-
- // setup
+func (suite *StatusBoostTestSuite) postStatusBoost(
+ targetStatusID string,
+ app *gtsmodel.Application,
+ token *gtsmodel.Token,
+ user *gtsmodel.User,
+ account *gtsmodel.Account,
+) (string, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Set(oauth.SessionAuthorizedApplication, app)
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
+ ctx.Set(oauth.SessionAuthorizedUser, user)
+ ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+ const pathBase = "http://localhost:8080/api" + statuses.ReblogPath
+ path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
+ ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json")
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
+ // Populate target status ID.
ctx.Params = gin.Params{
gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
+ Key: apiutil.IDKey,
+ Value: targetStatusID,
},
}
+ // Trigger handler.
suite.statusModule.StatusBoostPOSTHandler(ctx)
+ return suite.parseStatusResponse(recorder)
+}
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- statusReply := &apimodel.Status{}
- err = json.Unmarshal(b, statusReply)
- suite.NoError(err)
-
- suite.False(statusReply.Sensitive)
- suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
-
- suite.Empty(statusReply.SpoilerText)
- suite.Empty(statusReply.Content)
- suite.Equal("the_mighty_zork", statusReply.Account.Username)
- suite.Len(statusReply.MediaAttachments, 0)
- suite.Len(statusReply.Mentions, 0)
- suite.Len(statusReply.Emojis, 0)
- suite.Len(statusReply.Tags, 0)
-
- suite.NotNil(statusReply.Application)
- suite.Equal("really cool gts application", statusReply.Application.Name)
-
- suite.NotNil(statusReply.Reblog)
- suite.Equal(1, statusReply.Reblog.ReblogsCount)
- suite.Equal(1, statusReply.Reblog.FavouritesCount)
- suite.Equal(targetStatus.Content, statusReply.Reblog.Content)
- suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
- suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID)
- suite.Len(statusReply.Reblog.MediaAttachments, 1)
- suite.Len(statusReply.Reblog.Tags, 1)
- suite.Len(statusReply.Reblog.Emojis, 1)
- suite.True(statusReply.Reblogged)
- suite.True(statusReply.Reblog.Reblogged)
- suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name)
+func (suite *StatusBoostTestSuite) TestPostBoost() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_1"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": true,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": true,
+ "card": null,
+ "content": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "created_at": "right the hell just now babyee",
+ "emojis": [
+ {
+ "category": "reactions",
+ "shortcode": "rainbow",
+ "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
+ "url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
+ "visible_in_picker": true
+ }
+ ],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [
+ {
+ "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
+ "description": "Black and white image of some 50's style text saying: Welcome On Board",
+ "id": "01F8MH6NEM8D7527KZAECTCR76",
+ "meta": {
+ "focus": {
+ "x": 0,
+ "y": 0
+ },
+ "original": {
+ "aspect": 1.9047619,
+ "height": 630,
+ "size": "1200x630",
+ "width": 1200
+ },
+ "small": {
+ "aspect": 1.9104477,
+ "height": 268,
+ "size": "512x268",
+ "width": 512
+ }
+ },
+ "preview_remote_url": null,
+ "preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
+ "remote_url": null,
+ "text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
+ "type": "image",
+ "url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"
+ }
+ ],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 1,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [
+ {
+ "name": "welcome",
+ "url": "http://localhost:8080/tags/welcome"
+ }
+ ],
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+}`, out)
}
func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- testStatus := suite.testStatuses["local_account_1_status_5"]
- testAccount := suite.testAccounts["local_account_1"]
- testUser := suite.testUsers["local_account_1"]
-
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, testUser)
- ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
- ctx.Request.Header.Set("accept", "application/json")
-
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: testStatus.ID,
- },
- }
-
- suite.statusModule.StatusBoostPOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- responseStatus := &apimodel.Status{}
- err = json.Unmarshal(b, responseStatus)
- suite.NoError(err)
-
- suite.False(responseStatus.Sensitive)
- suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility)
-
- suite.Empty(responseStatus.SpoilerText)
- suite.Empty(responseStatus.Content)
- suite.Equal("the_mighty_zork", responseStatus.Account.Username)
- suite.Len(responseStatus.MediaAttachments, 0)
- suite.Len(responseStatus.Mentions, 0)
- suite.Len(responseStatus.Emojis, 0)
- suite.Len(responseStatus.Tags, 0)
-
- suite.NotNil(responseStatus.Application)
- suite.Equal("really cool gts application", responseStatus.Application.Name)
-
- suite.NotNil(responseStatus.Reblog)
- suite.Equal(1, responseStatus.Reblog.ReblogsCount)
- suite.Equal(0, responseStatus.Reblog.FavouritesCount)
- suite.Equal(testStatus.Content, responseStatus.Reblog.Content)
- suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText)
- suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID)
- suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility)
- suite.Empty(responseStatus.Reblog.MediaAttachments)
- suite.Empty(responseStatus.Reblog.Tags)
- suite.Empty(responseStatus.Reblog.Emojis)
- suite.True(responseStatus.Reblogged)
- suite.True(responseStatus.Reblog.Reblogged)
- suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name)
+ var (
+ targetStatus = suite.testStatuses["local_account_1_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "hi!",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "hi!",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "private"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "private"
+}`, out)
}
-// try to boost a status that's not boostable / visible to us
+// Try to boost a status that's
+// not boostable / visible to us.
func (suite *StatusBoostTestSuite) TestPostUnboostable() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["local_account_2_status_4"]
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusBoostPOSTHandler(ctx)
-
- // check response
+ var (
+ targetStatus = suite.testStatuses["local_account_2_status_4"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have 403 from
+ // our call to the function.
suite.Equal(http.StatusForbidden, recorder.Code)
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
- suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b))
+ // We should have a helpful message.
+ suite.Equal(`{
+ "error": "Forbidden: you do not have permission to boost this status"
+}`, out)
}
-// try to boost a status that's not visible to the user
+// Try to boost a status that's not visible to the user.
func (suite *StatusBoostTestSuite) TestPostNotVisible() {
- // stop local_account_2 following zork
- err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, &gtsmodel.Follow{})
- suite.NoError(err)
-
- t := suite.testTokens["local_account_2"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
+ // Stop local_account_2 following zork.
+ err := suite.db.DeleteFollowByID(
+ context.Background(),
+ suite.testFollows["local_account_2_local_account_1"].ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
}
- suite.statusModule.StatusBoostPOSTHandler(ctx)
+ var (
+ // This is a mutual only status and
+ // these accounts aren't mutuals anymore.
+ targetStatus = suite.testStatuses["local_account_1_status_3"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have 404 from
+ // our call to the function.
+ suite.Equal(http.StatusNotFound, recorder.Code)
+
+ // We should have a helpful message.
+ suite.Equal(`{
+ "error": "Not Found: target status not found"
+}`, out)
+}
- // check response
- suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
+// Boost a status that's pending approval by us.
+func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "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>",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "acct": "1happyturtle",
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "url": "http://localhost:8080/@1happyturtle",
+ "username": "1happyturtle"
+ }
+ ],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "Hi @1happyturtle, can I reply?",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+}`, out)
+
+ // Target status should no
+ // longer be pending approval.
+ dbStatus, err := suite.state.DB.GetStatusByID(
+ context.Background(),
+ targetStatus.ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(*dbStatus.PendingApproval)
+
+ // There should be an Accept
+ // stored for the target status.
+ intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
+ context.Background(), targetStatus.URI,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.NotZero(intReq.AcceptedAt)
+ suite.NotEmpty(intReq.URI)
}
func TestStatusBoostTestSuite(t *testing.T) {
diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go
index d32feb6c7..8598b5ef0 100644
--- a/internal/api/client/statuses/statuscreate_test.go
+++ b/internal/api/client/statuses/statuscreate_test.go
@@ -20,18 +20,14 @@ package statuses_test
import (
"bytes"
"context"
- "encoding/json"
"fmt"
- "io"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -81,91 +77,7 @@ func (suite *StatusCreateTestSuite) postStatus(
// Trigger handler.
suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- data, err := io.ReadAll(result.Body)
- if err != nil {
- suite.FailNow(err.Error())
- }
-
- rawMap := make(map[string]any)
- if err := json.Unmarshal(data, &rawMap); err != nil {
- suite.FailNow(err.Error())
- }
-
- // Replace any fields from the raw map that
- // aren't determinate (date, id, url, etc).
- if _, ok := rawMap["id"]; ok {
- rawMap["id"] = id.Highest
- }
-
- if _, ok := rawMap["uri"]; ok {
- rawMap["uri"] = "http://localhost:8080/some/determinate/url"
- }
-
- if _, ok := rawMap["url"]; ok {
- rawMap["url"] = "http://localhost:8080/some/determinate/url"
- }
-
- if _, ok := rawMap["created_at"]; ok {
- rawMap["created_at"] = "right the hell just now babyee"
- }
-
- // Make ID of any mentions determinate.
- if menchiesRaw, ok := rawMap["mentions"]; ok {
- menchies, ok := menchiesRaw.([]any)
- if !ok {
- suite.FailNow("couldn't coerce menchies")
- }
-
- for _, menchieRaw := range menchies {
- menchie, ok := menchieRaw.(map[string]any)
- if !ok {
- suite.FailNow("couldn't coerce menchie")
- }
-
- if _, ok := menchie["id"]; ok {
- menchie["id"] = id.Highest
- }
- }
- }
-
- // Make fields of any poll determinate.
- if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
- poll, ok := pollRaw.(map[string]any)
- if !ok {
- suite.FailNow("couldn't coerce poll")
- }
-
- if _, ok := poll["id"]; ok {
- poll["id"] = id.Highest
- }
-
- if _, ok := poll["expires_at"]; ok {
- poll["expires_at"] = "ah like you know whatever dude it's chill"
- }
- }
-
- // Replace account since that's not really
- // what we care about for these tests.
- if _, ok := rawMap["account"]; ok {
- rawMap["account"] = "yeah this is my account, what about it punk"
- }
-
- // For readability, don't
- // escape HTML, and indent json.
- out := new(bytes.Buffer)
- enc := json.NewEncoder(out)
- enc.SetEscapeHTML(false)
- enc.SetIndent("", " ")
-
- if err := enc.Encode(&rawMap); err != nil {
- suite.FailNow(err.Error())
- }
-
- return strings.TrimSpace(out.String()), recorder
+ return suite.parseStatusResponse(recorder)
}
// Post a new status with some custom visibility settings
@@ -447,7 +359,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMessedUpIntPolicy() {
suite.Equal(http.StatusBadRequest, recorder.Code)
// We should have a helpful error
- // message telling us how we screwed up.
+ // message telling us how we screwed up.
suite.Equal(`{
"error": "Bad Request: error converting followers_only.can_reply.always: policyURI public is not feasible for visibility followers_only"
}`, out)
diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go
index d1042b10e..fdc8741c7 100644
--- a/internal/api/client/statuses/statusfave_test.go
+++ b/internal/api/client/statuses/statusfave_test.go
@@ -18,20 +18,18 @@
package statuses_test
import (
- "encoding/json"
- "fmt"
- "io/ioutil"
+ "context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -40,90 +38,260 @@ type StatusFaveTestSuite struct {
StatusStandardTestSuite
}
-// fave a status
-func (suite *StatusFaveTestSuite) TestPostFave() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_2"]
-
- // setup
+func (suite *StatusFaveTestSuite) postStatusFave(
+ targetStatusID string,
+ app *gtsmodel.Application,
+ token *gtsmodel.Token,
+ user *gtsmodel.User,
+ account *gtsmodel.Account,
+) (string, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Set(oauth.SessionAuthorizedApplication, app)
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
+ ctx.Set(oauth.SessionAuthorizedUser, user)
+ ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+ const pathBase = "http://localhost:8080/api" + statuses.FavouritePath
+ path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
+ ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json")
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
+ // Populate target status ID.
ctx.Params = gin.Params{
gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
+ Key: apiutil.IDKey,
+ Value: targetStatusID,
},
}
+ // Trigger handler.
suite.statusModule.StatusFavePOSTHandler(ctx)
+ return suite.parseStatusResponse(recorder)
+}
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
+// Fave a status we haven't faved yet.
+func (suite *StatusFaveTestSuite) TestPostFave() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_2"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- statusReply := &apimodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
- assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
- assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
- assert.True(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility)
- assert.True(suite.T(), statusReply.Favourited)
- assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+ // Target status should now
+ // be "favourited" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "🐕🐕🐕🐕🐕",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": false,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": true,
+ "spoiler_text": "open to see some puppies",
+ "tags": [],
+ "text": "🐕🐕🐕🐕🐕",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+}`, out)
}
-// try to fave a status that's not faveable
+// Try to fave a status
+// that's not faveable by us.
func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
- t := suite.testTokens["admin_account"]
- oauthToken := oauth.DBTokenToToken(t)
+ var (
+ targetStatus = suite.testStatuses["local_account_1_status_3"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["admin_account"]
+ user = suite.testUsers["admin_account"]
+ account = suite.testAccounts["admin_account"]
+ )
- targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
+ // We should have 403 from
+ // our call to the function.
+ suite.Equal(http.StatusForbidden, recorder.Code)
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
- }
+ // We should get a helpful error.
+ suite.Equal(`{
+ "error": "Forbidden: you do not have permission to fave this status"
+}`, out)
+}
- suite.statusModule.StatusFavePOSTHandler(ctx)
+// Fave a status that's pending approval by us.
+func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
- // check response
- suite.EqualValues(http.StatusForbidden, recorder.Code)
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b))
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "favourited" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "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>",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "acct": "1happyturtle",
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "url": "http://localhost:8080/@1happyturtle",
+ "username": "1happyturtle"
+ }
+ ],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": false,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "Hi @1happyturtle, can I reply?",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+}`, out)
+
+ // Target status should no
+ // longer be pending approval.
+ dbStatus, err := suite.state.DB.GetStatusByID(
+ context.Background(),
+ targetStatus.ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(*dbStatus.PendingApproval)
+
+ // There should be an Accept
+ // stored for the target status.
+ intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
+ context.Background(), targetStatus.URI,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.NotZero(intReq.AcceptedAt)
+ suite.NotEmpty(intReq.URI)
}
func TestStatusFaveTestSuite(t *testing.T) {
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 2ed13d396..ce0f1cfb8 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -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.
diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go
index 1b6e8bd47..0e09a8e7b 100644
--- a/internal/processing/status/boost.go
+++ b/internal/processing/status/boost.go
@@ -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)
}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 1513018ae..184a92680 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -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)
}
diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go
index 497c4d465..defc59af0 100644
--- a/internal/processing/status/fave.go
+++ b/internal/processing/status/fave.go
@@ -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)
}
diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go
index 7e614cc31..26dfd0d7a 100644
--- a/internal/processing/status/status.go
+++ b/internal/processing/status/status.go
@@ -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,
}
}
diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go
index f0b22b2c1..b3c446d14 100644
--- a/internal/processing/status/status_test.go
+++ b/internal/processing/status/status_test.go
@@ -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,
diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go
new file mode 100644
index 000000000..99cff7c56
--- /dev/null
+++ b/internal/processing/status/util.go
@@ -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
+}
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": "<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)
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(`<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
diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx
index 86bcf4243..091dd40ae 100644
--- a/web/source/settings/views/user/router.tsx
+++ b/web/source/settings/views/user/router.tsx
@@ -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>
);