summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar Vyr Cossont <VyrCossont@users.noreply.github.com>2024-06-06 09:38:02 -0700
committerLibravatar GitHub <noreply@github.com>2024-06-06 16:38:02 +0000
commit5e2d4fdb19eb4fcd4c0bbfb3e2f29067a58c88c8 (patch)
tree607006af6b4bb63bb625b39f3ca0fe869eb6ba95 /internal
parent[bugfix] update media if more than just url changes (#2970) (diff)
downloadgotosocial-5e2d4fdb19eb4fcd4c0bbfb3e2f29067a58c88c8.tar.xz
[feature] User muting (#2960)
* User muting * Address review feedback * Rename uniqueness constraint on user_mutes to match convention * Remove unused account_id from where clause * Add UserMute to NewTestDB * Update test/envparsing.sh with new and fixed cache stuff * Address tobi's review comments * Make compiledUserMuteListEntry.expired consistent with UserMute.Expired * Make sure mute_expires_at is serialized as an explicit null for indefinite mutes --------- Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/accounts/accounts.go6
-rw-r--r--internal/api/client/accounts/mute.go170
-rw-r--r--internal/api/client/accounts/mute_test.go173
-rw-r--r--internal/api/client/accounts/unmute.go98
-rw-r--r--internal/api/client/accounts/unmute_test.go136
-rw-r--r--internal/api/client/mutes/mutes_test.go136
-rw-r--r--internal/api/client/mutes/mutesget.go35
-rw-r--r--internal/api/client/mutes/mutesget_test.go155
-rw-r--r--internal/api/model/account.go14
-rw-r--r--internal/api/model/usermute.go34
-rw-r--r--internal/cache/cache.go4
-rw-r--r--internal/cache/db.go53
-rw-r--r--internal/cache/invalidate.go5
-rw-r--r--internal/cache/size.go12
-rw-r--r--internal/config/config.go4
-rw-r--r--internal/config/defaults.go2
-rw-r--r--internal/config/helpers.gen.go55
-rw-r--r--internal/db/bundb/migrations/20240528071620_add_user_mutes.go61
-rw-r--r--internal/db/bundb/relationship.go11
-rw-r--r--internal/db/bundb/relationship_mute.go306
-rw-r--r--internal/db/bundb/relationship_test.go37
-rw-r--r--internal/db/relationship.go21
-rw-r--r--internal/filter/usermute/usermute.go80
-rw-r--r--internal/gtsmodel/usermute.go41
-rw-r--r--internal/processing/account/bookmarks.go2
-rw-r--r--internal/processing/account/mute.go198
-rw-r--r--internal/processing/account/statuses.go2
-rw-r--r--internal/processing/common/status.go2
-rw-r--r--internal/processing/search/util.go2
-rw-r--r--internal/processing/status/get.go12
-rw-r--r--internal/processing/stream/statusupdate_test.go2
-rw-r--r--internal/processing/timeline/faved.go2
-rw-r--r--internal/processing/timeline/home.go11
-rw-r--r--internal/processing/timeline/list.go11
-rw-r--r--internal/processing/timeline/notification.go25
-rw-r--r--internal/processing/timeline/public.go12
-rw-r--r--internal/processing/timeline/tag.go11
-rw-r--r--internal/processing/workers/fromclientapi_test.go3
-rw-r--r--internal/processing/workers/surfacenotify.go13
-rw-r--r--internal/processing/workers/surfacetimeline.go26
-rw-r--r--internal/typeutils/converter.go3
-rw-r--r--internal/typeutils/internaltofrontend.go93
-rw-r--r--internal/typeutils/internaltofrontend_test.go63
43 files changed, 2099 insertions, 43 deletions
diff --git a/internal/api/client/accounts/accounts.go b/internal/api/client/accounts/accounts.go
index 61fdc41ad..0dbc5ea53 100644
--- a/internal/api/client/accounts/accounts.go
+++ b/internal/api/client/accounts/accounts.go
@@ -45,12 +45,14 @@ const (
FollowPath = BasePathWithID + "/follow"
ListsPath = BasePathWithID + "/lists"
LookupPath = BasePath + "/lookup"
+ MutePath = BasePathWithID + "/mute"
NotePath = BasePathWithID + "/note"
RelationshipsPath = BasePath + "/relationships"
SearchPath = BasePath + "/search"
StatusesPath = BasePathWithID + "/statuses"
UnblockPath = BasePathWithID + "/unblock"
UnfollowPath = BasePathWithID + "/unfollow"
+ UnmutePath = BasePathWithID + "/unmute"
UpdatePath = BasePath + "/update_credentials"
VerifyPath = BasePath + "/verify_credentials"
MovePath = BasePath + "/move"
@@ -117,6 +119,10 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// account note
attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler)
+ // mute or unmute account
+ attachHandler(http.MethodPost, MutePath, m.AccountMutePOSTHandler)
+ attachHandler(http.MethodPost, UnmutePath, m.AccountUnmutePOSTHandler)
+
// search for accounts
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
diff --git a/internal/api/client/accounts/mute.go b/internal/api/client/accounts/mute.go
new file mode 100644
index 000000000..37cd3bbff
--- /dev/null
+++ b/internal/api/client/accounts/mute.go
@@ -0,0 +1,170 @@
+// 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 accounts
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// AccountMutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/mute accountMute
+//
+// Mute account by ID.
+//
+// If account was already muted, succeeds anyway. This can be used to update the details of a mute.
+//
+// ---
+// tags:
+// - accounts
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: The ID of the account to block.
+// in: path
+// required: true
+// -
+// name: notifications
+// type: boolean
+// description: Mute notifications as well as posts.
+// in: formData
+// required: false
+// default: false
+// -
+// name: duration
+// type: number
+// description: How long the mute should last, in seconds. If 0 or not provided, mute lasts indefinitely.
+// in: formData
+// required: false
+// default: 0
+//
+// security:
+// - OAuth2 Bearer:
+// - write:mutes
+//
+// responses:
+// '200':
+// description: Your relationship to the account.
+// schema:
+// "$ref": "#/definitions/accountRelationship"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden to moved accounts
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) AccountMutePOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ targetAcctID := c.Param(IDKey)
+ if targetAcctID == "" {
+ err := errors.New("no account id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form := &apimodel.UserMuteCreateUpdateRequest{}
+ if err := c.ShouldBind(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if err := normalizeCreateUpdateMute(form); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ relationship, errWithCode := m.processor.Account().MuteCreate(
+ c.Request.Context(),
+ authed.Account,
+ targetAcctID,
+ form,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, relationship)
+}
+
+func normalizeCreateUpdateMute(form *apimodel.UserMuteCreateUpdateRequest) error {
+ // Apply defaults for missing fields.
+ form.Notifications = util.Ptr(util.PtrValueOr(form.Notifications, false))
+
+ // Normalize mute duration if necessary.
+ // If we parsed this as JSON, expires_in
+ // may be either a float64 or a string.
+ if ei := form.DurationI; ei != nil {
+ switch e := ei.(type) {
+ case float64:
+ form.Duration = util.Ptr(int(e))
+
+ case string:
+ duration, err := strconv.Atoi(e)
+ if err != nil {
+ return fmt.Errorf("could not parse duration value %s as integer: %w", e, err)
+ }
+
+ form.Duration = &duration
+
+ default:
+ return fmt.Errorf("could not parse expires_in type %T as integer", ei)
+ }
+ }
+
+ // Interpret zero as indefinite duration.
+ if form.Duration != nil && *form.Duration == 0 {
+ form.Duration = nil
+ }
+
+ return nil
+}
diff --git a/internal/api/client/accounts/mute_test.go b/internal/api/client/accounts/mute_test.go
new file mode 100644
index 000000000..d181a2e3b
--- /dev/null
+++ b/internal/api/client/accounts/mute_test.go
@@ -0,0 +1,173 @@
+// 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 accounts_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type MuteTestSuite struct {
+ AccountStandardTestSuite
+}
+
+func (suite *MuteTestSuite) postMute(
+ accountID string,
+ notifications *bool,
+ duration *int,
+ requestJson *string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Relationship, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/mute", nil)
+ ctx.Request.Header.Set("accept", "application/json")
+ if requestJson != nil {
+ ctx.Request.Header.Set("content-type", "application/json")
+ ctx.Request.Body = io.NopCloser(strings.NewReader(*requestJson))
+ } else {
+ ctx.Request.Form = make(url.Values)
+ if notifications != nil {
+ ctx.Request.Form["notifications"] = []string{strconv.FormatBool(*notifications)}
+ }
+ if duration != nil {
+ ctx.Request.Form["duration"] = []string{strconv.Itoa(*duration)}
+ }
+ }
+
+ ctx.AddParam("id", accountID)
+
+ // trigger the handler
+ suite.accountsModule.AccountMutePOSTHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ if expectedBody == "" {
+ return nil, errs.Combine()
+ }
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.Relationship{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *MuteTestSuite) TestPostMuteFull() {
+ accountID := suite.testAccounts["remote_account_1"].ID
+ notifications := true
+ duration := 86400
+ relationship, err := suite.postMute(accountID, &notifications, &duration, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.True(relationship.Muting)
+ suite.Equal(notifications, relationship.MutingNotifications)
+}
+
+func (suite *MuteTestSuite) TestPostMuteFullJSON() {
+ accountID := suite.testAccounts["remote_account_2"].ID
+ // Use a numeric literal with a fractional part to test the JSON-specific handling for non-integer "duration".
+ requestJson := `{
+ "notifications": true,
+ "duration": 86400.1
+ }`
+ relationship, err := suite.postMute(accountID, nil, nil, &requestJson, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.True(relationship.Muting)
+ suite.True(relationship.MutingNotifications)
+}
+
+func (suite *MuteTestSuite) TestPostMuteMinimal() {
+ accountID := suite.testAccounts["remote_account_3"].ID
+ relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.True(relationship.Muting)
+ suite.False(relationship.MutingNotifications)
+}
+
+func (suite *MuteTestSuite) TestPostMuteSelf() {
+ accountID := suite.testAccounts["local_account_1"].ID
+ _, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *MuteTestSuite) TestPostMuteNonexistentAccount() {
+ accountID := "not_even_a_real_ULID"
+ _, err := suite.postMute(accountID, nil, nil, nil, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func TestMuteTestSuite(t *testing.T) {
+ suite.Run(t, new(MuteTestSuite))
+}
diff --git a/internal/api/client/accounts/unmute.go b/internal/api/client/accounts/unmute.go
new file mode 100644
index 000000000..665c3908e
--- /dev/null
+++ b/internal/api/client/accounts/unmute.go
@@ -0,0 +1,98 @@
+// 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 accounts
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// AccountUnmutePOSTHandler swagger:operation POST /api/v1/accounts/{id}/unmute accountUnmute
+//
+// Unmute account by ID.
+//
+// If account was already unmuted (or has never been muted), succeeds anyway.
+//
+// ---
+// tags:
+// - accounts
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: The ID of the account to unmute.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:mutes
+//
+// responses:
+// '200':
+// name: account relationship
+// description: Your relationship to this account.
+// schema:
+// "$ref": "#/definitions/accountRelationship"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) AccountUnmutePOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ targetAcctID := c.Param(IDKey)
+ if targetAcctID == "" {
+ err := errors.New("no account id specified")
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ relationship, errWithCode := m.processor.Account().MuteRemove(c.Request.Context(), authed.Account, targetAcctID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, relationship)
+
+}
diff --git a/internal/api/client/accounts/unmute_test.go b/internal/api/client/accounts/unmute_test.go
new file mode 100644
index 000000000..5a00c3610
--- /dev/null
+++ b/internal/api/client/accounts/unmute_test.go
@@ -0,0 +1,136 @@
+// 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 accounts_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/accounts"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *MuteTestSuite) postUnmute(
+ accountID string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Relationship, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodPut, config.GetProtocol()+"://"+config.GetHost()+"/api/"+accounts.BasePath+"/"+accountID+"/unmute", nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.AddParam("id", accountID)
+
+ // trigger the handler
+ suite.accountsModule.AccountUnmutePOSTHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ if expectedBody == "" {
+ return nil, errs.Combine()
+ }
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.Relationship{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *MuteTestSuite) TestPostUnmuteWithoutPreviousMute() {
+ accountID := suite.testAccounts["remote_account_4"].ID
+ relationship, err := suite.postUnmute(accountID, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.False(relationship.Muting)
+ suite.False(relationship.MutingNotifications)
+}
+
+func (suite *MuteTestSuite) TestPostWithPreviousMute() {
+ accountID := suite.testAccounts["local_account_2"].ID
+
+ relationship, err := suite.postMute(accountID, nil, nil, nil, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.True(relationship.Muting)
+ suite.False(relationship.MutingNotifications)
+
+ relationship, err = suite.postUnmute(accountID, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.False(relationship.Muting)
+ suite.False(relationship.MutingNotifications)
+}
+
+func (suite *MuteTestSuite) TestPostUnmuteSelf() {
+ accountID := suite.testAccounts["local_account_1"].ID
+ _, err := suite.postUnmute(accountID, http.StatusNotAcceptable, `{"error":"Not Acceptable: getMuteTarget: account 01F8MH1H7YV1Z7D2C8K2730QBF cannot mute or unmute itself"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
+
+func (suite *MuteTestSuite) TestPostUnmuteNonexistentAccount() {
+ accountID := "not_even_a_real_ULID"
+ _, err := suite.postUnmute(accountID, http.StatusNotFound, `{"error":"Not Found: getMuteTarget: target account not_even_a_real_ULID not found in the db"}`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/mutes/mutes_test.go b/internal/api/client/mutes/mutes_test.go
new file mode 100644
index 000000000..5d450e32c
--- /dev/null
+++ b/internal/api/client/mutes/mutes_test.go
@@ -0,0 +1,136 @@
+// 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 mutes_test
+
+import (
+ "bytes"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/mutes"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type MutesTestSuite struct {
+ // standard suite interfaces
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+
+ // module being tested
+ mutesModule *mutes.Module
+}
+
+func (suite *MutesTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+}
+
+func (suite *MutesTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ testrig.StartTimelines(
+ &suite.state,
+ visibility.NewFilter(&suite.state),
+ typeutils.NewConverter(&suite.state),
+ )
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
+ suite.sentEmails = make(map[string]string)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
+ suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.mutesModule = mutes.New(suite.processor)
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *MutesTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+func (suite *MutesTestSuite) newContext(recorder *httptest.ResponseRecorder, requestMethod string, requestBody []byte, requestPath string, bodyContentType string) *gin.Context {
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ protocol := config.GetProtocol()
+ host := config.GetHost()
+
+ baseURI := fmt.Sprintf("%s://%s", protocol, host)
+ requestURI := fmt.Sprintf("%s/%s", baseURI, requestPath)
+
+ ctx.Request = httptest.NewRequest(requestMethod, requestURI, bytes.NewReader(requestBody)) // the endpoint we're hitting
+
+ if bodyContentType != "" {
+ ctx.Request.Header.Set("Content-Type", bodyContentType)
+ }
+
+ ctx.Request.Header.Set("accept", "application/json")
+
+ return ctx
+}
+
+func TestMutesTestSuite(t *testing.T) {
+ suite.Run(t, new(MutesTestSuite))
+}
diff --git a/internal/api/client/mutes/mutesget.go b/internal/api/client/mutes/mutesget.go
index d609da868..7fcbc2b44 100644
--- a/internal/api/client/mutes/mutesget.go
+++ b/internal/api/client/mutes/mutesget.go
@@ -24,14 +24,13 @@ import (
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
)
// MutesGETHandler swagger:operation GET /api/v1/mutes mutesGet
//
// Get an array of accounts that requesting account has muted.
//
-// NOT IMPLEMENTED YET: Will currently always return an array of length 0.
-//
// The next and previous queries can be parsed from the returned Link header.
// Example:
//
@@ -89,6 +88,7 @@ import (
//
// responses:
// '200':
+// description: List of muted accounts, including when their mutes expire (if applicable).
// headers:
// Link:
// type: string
@@ -96,7 +96,7 @@ import (
// schema:
// type: array
// items:
-// "$ref": "#/definitions/account"
+// "$ref": "#/definitions/mutedAccount"
// '400':
// description: bad request
// '401':
@@ -108,7 +108,8 @@ import (
// '500':
// description: internal server error
func (m *Module) MutesGETHandler(c *gin.Context) {
- if _, err := oauth.Authed(c, true, true, true, true); err != nil {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
@@ -118,5 +119,29 @@ func (m *Module) MutesGETHandler(c *gin.Context) {
return
}
- apiutil.Data(c, http.StatusOK, apiutil.AppJSON, apiutil.EmptyJSONArray)
+ page, errWithCode := paging.ParseIDPage(c,
+ 1, // min limit
+ 80, // max limit
+ 40, // default limit
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ resp, errWithCode := m.processor.Account().MutesGet(
+ c.Request.Context(),
+ authed.Account,
+ page,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ if resp.LinkHeader != "" {
+ c.Header("Link", resp.LinkHeader)
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp.Items)
}
diff --git a/internal/api/client/mutes/mutesget_test.go b/internal/api/client/mutes/mutesget_test.go
new file mode 100644
index 000000000..2e5a00c6a
--- /dev/null
+++ b/internal/api/client/mutes/mutesget_test.go
@@ -0,0 +1,155 @@
+// 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 mutes_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/mutes"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *MutesTestSuite) getMutedAccounts(
+ expectedHTTPStatus int,
+ expectedBody string,
+) ([]*apimodel.MutedAccount, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+mutes.BasePath, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // trigger the handler
+ suite.mutesModule.MutesGETHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ if expectedBody == "" {
+ return nil, errs.Combine()
+ }
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := make([]*apimodel.MutedAccount, 0)
+ if err := json.Unmarshal(b, &resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func (suite *MutesTestSuite) TestGetMutedAccounts() {
+ // Mute a user with a finite duration.
+ mute1 := &gtsmodel.UserMute{
+ ID: "01HZQ4K4MJTZ3RWVAEEJQDKK7M",
+ ExpiresAt: time.Now().Add(time.Duration(1) * time.Hour),
+ AccountID: suite.testAccounts["local_account_1"].ID,
+ TargetAccountID: suite.testAccounts["local_account_2"].ID,
+ }
+ err := suite.db.PutMute(context.Background(), mute1)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Mute a user with an indefinite duration.
+ mute2 := &gtsmodel.UserMute{
+ ID: "01HZQ4K641EMWBEJ9A99WST1GP",
+ AccountID: suite.testAccounts["local_account_1"].ID,
+ TargetAccountID: suite.testAccounts["remote_account_1"].ID,
+ }
+ err = suite.db.PutMute(context.Background(), mute2)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Fetch all muted accounts for the logged-in account.
+ mutedAccounts, err := suite.getMutedAccounts(http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.NotEmpty(mutedAccounts)
+
+ // Check that we got the accounts we just muted, and that their mute expiration times are set correctly.
+ // Note that the account list will be in *reverse* order by mute ID.
+ if suite.Len(mutedAccounts, 2) {
+ // This mute expiration should be a string.
+ mutedAccount1 := mutedAccounts[1]
+ suite.Equal(mute1.TargetAccountID, mutedAccount1.ID)
+ suite.NotEmpty(mutedAccount1.MuteExpiresAt)
+
+ // This mute expiration should be null.
+ mutedAccount2 := mutedAccounts[0]
+ suite.Equal(mute2.TargetAccountID, mutedAccount2.ID)
+ suite.Nil(mutedAccount2.MuteExpiresAt)
+ }
+}
+
+func (suite *MutesTestSuite) TestIndefinitelyMutedAccountSerializesMuteExpirationAsNull() {
+ // Mute a user with an indefinite duration.
+ mute := &gtsmodel.UserMute{
+ ID: "01HZQ4K641EMWBEJ9A99WST1GP",
+ AccountID: suite.testAccounts["local_account_1"].ID,
+ TargetAccountID: suite.testAccounts["remote_account_1"].ID,
+ }
+ err := suite.db.PutMute(context.Background(), mute)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Fetch all muted accounts for the logged-in account.
+ // The expected body contains `"mute_expires_at":null`.
+ _, err = suite.getMutedAccounts(http.StatusOK, `[{"id":"01F8MH5ZK5VRH73AKHQM6Y9VNX","username":"foss_satan","acct":"foss_satan@fossbros-anonymous.io","display_name":"big gerald","locked":false,"discoverable":true,"bot":false,"created_at":"2021-09-26T10:52:36.000Z","note":"i post about like, i dunno, stuff, or whatever!!!!","url":"http://fossbros-anonymous.io/@foss_satan","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":0,"following_count":0,"statuses_count":3,"last_status_at":"2021-09-11T09:40:37.000Z","emojis":[],"fields":[],"mute_expires_at":null}]`)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/model/account.go b/internal/api/model/account.go
index a2f7b46b6..b3a92d36f 100644
--- a/internal/api/model/account.go
+++ b/internal/api/model/account.go
@@ -86,9 +86,6 @@ type Account struct {
Fields []Field `json:"fields"`
// Account has been suspended by our instance.
Suspended bool `json:"suspended,omitempty"`
- // If this account has been muted, when will the mute expire (ISO 8601 Datetime).
- // example: 2021-07-30T09:20:25+00:00
- MuteExpiresAt string `json:"mute_expires_at,omitempty"`
// Extra profile information. Shown only if the requester owns the account being requested.
Source *Source `json:"source,omitempty"`
// Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`.
@@ -109,6 +106,17 @@ type Account struct {
Moved *Account `json:"moved,omitempty"`
}
+// MutedAccount extends Account with a field used only by the muted user list.
+//
+// swagger:model mutedAccount
+type MutedAccount struct {
+ Account
+ // If this account has been muted, when will the mute expire (ISO 8601 Datetime).
+ // If the mute is indefinite, this will be null.
+ // example: 2021-07-30T09:20:25+00:00
+ MuteExpiresAt *string `json:"mute_expires_at"`
+}
+
// AccountCreateRequest models account creation parameters.
//
// swagger:parameters accountCreate
diff --git a/internal/api/model/usermute.go b/internal/api/model/usermute.go
new file mode 100644
index 000000000..138f837db
--- /dev/null
+++ b/internal/api/model/usermute.go
@@ -0,0 +1,34 @@
+// 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 model
+
+// UserMuteCreateUpdateRequest captures params for creating or updating a user mute.
+//
+// swagger:ignore
+type UserMuteCreateUpdateRequest struct {
+ // Should the mute apply to notifications from that user?
+ //
+ // Example: true
+ Notifications *bool `form:"notifications" json:"notifications" xml:"notifications"`
+ // Number of seconds from now that the mute should expire. If omitted or 0, mute never expires.
+ Duration *int `json:"-" form:"duration" xml:"duration"`
+ // Number of seconds from now that the mute should expire. If omitted or 0, mute never expires.
+ //
+ // Example: 86400
+ DurationI interface{} `json:"duration"`
+}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 2af5e20ca..bb910f3e6 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -94,6 +94,8 @@ func (c *Caches) Init() {
c.initToken()
c.initTombstone()
c.initUser()
+ c.initUserMute()
+ c.initUserMuteIDs()
c.initWebfinger()
c.initVisibility()
}
@@ -164,5 +166,7 @@ func (c *Caches) Sweep(threshold float64) {
c.GTS.Token.Trim(threshold)
c.GTS.Tombstone.Trim(threshold)
c.GTS.User.Trim(threshold)
+ c.GTS.UserMute.Trim(threshold)
+ c.GTS.UserMuteIDs.Trim(threshold)
c.Visibility.Trim(threshold)
}
diff --git a/internal/cache/db.go b/internal/cache/db.go
index a5325f6ef..e00c02701 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -47,7 +47,7 @@ type GTSCaches struct {
// Block provides access to the gtsmodel Block (account) database cache.
Block StructCache[*gtsmodel.Block]
- // FollowIDs provides access to the block IDs database cache.
+ // BlockIDs provides access to the block IDs database cache.
BlockIDs SliceCache[string]
// BoostOfIDs provides access to the boost of IDs list database cache.
@@ -166,6 +166,12 @@ type GTSCaches struct {
// User provides access to the gtsmodel User database cache.
User StructCache[*gtsmodel.User]
+ // UserMute provides access to the gtsmodel UserMute database cache.
+ UserMute StructCache[*gtsmodel.UserMute]
+
+ // UserMuteIDs provides access to the user mute IDs database cache.
+ UserMuteIDs SliceCache[string]
+
// Webfinger provides access to the webfinger URL cache.
// TODO: move out of GTS caches since unrelated to DB.
Webfinger *ttl.Cache[string, string] // TTL=24hr, sweep=5min
@@ -1347,6 +1353,51 @@ func (c *Caches) initUser() {
})
}
+func (c *Caches) initUserMute() {
+ cap := calculateResultCacheMax(
+ sizeofUserMute(), // model in-mem size.
+ config.GetCacheUserMuteMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ copyF := func(u1 *gtsmodel.UserMute) *gtsmodel.UserMute {
+ u2 := new(gtsmodel.UserMute)
+ *u2 = *u1
+
+ // Don't include ptr fields that
+ // will be populated separately.
+ // See internal/db/bundb/relationship_mute.go.
+ u2.Account = nil
+ u2.TargetAccount = nil
+
+ return u2
+ }
+
+ c.GTS.UserMute.Init(structr.CacheConfig[*gtsmodel.UserMute]{
+ Indices: []structr.IndexConfig{
+ {Fields: "ID"},
+ {Fields: "AccountID,TargetAccountID"},
+ {Fields: "AccountID", Multiple: true},
+ {Fields: "TargetAccountID", Multiple: true},
+ },
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Copy: copyF,
+ Invalidate: c.OnInvalidateUserMute,
+ })
+}
+
+func (c *Caches) initUserMuteIDs() {
+ cap := calculateSliceCacheMax(
+ config.GetCacheUserMuteIDsMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ c.GTS.UserMuteIDs.Init(0, cap)
+}
+
func (c *Caches) initWebfinger() {
// Calculate maximum cache size.
cap := calculateCacheMax(
diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go
index 9c626d7a9..088e7f91f 100644
--- a/internal/cache/invalidate.go
+++ b/internal/cache/invalidate.go
@@ -213,3 +213,8 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
c.Visibility.Invalidate("ItemID", user.AccountID)
c.Visibility.Invalidate("RequesterID", user.AccountID)
}
+
+func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
+ // Invalidate source account's user mute lists.
+ c.GTS.UserMuteIDs.Invalidate(mute.AccountID)
+}
diff --git a/internal/cache/size.go b/internal/cache/size.go
index e205bf023..e1529f741 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -715,3 +715,15 @@ func sizeofUser() uintptr {
ExternalID: exampleID,
}))
}
+
+func sizeofUserMute() uintptr {
+ return uintptr(size.Of(&gtsmodel.UserMute{
+ ID: exampleID,
+ CreatedAt: exampleTime,
+ UpdatedAt: exampleTime,
+ ExpiresAt: exampleTime,
+ AccountID: exampleID,
+ TargetAccountID: exampleID,
+ Notifications: util.Ptr(false),
+ }))
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index f738ba797..8d410f6ac 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -198,7 +198,7 @@ type CacheConfiguration struct {
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
ApplicationMemRatio float64 `name:"application-mem-ratio"`
BlockMemRatio float64 `name:"block-mem-ratio"`
- BlockIDsMemRatio float64 `name:"block-mem-ratio"`
+ BlockIDsMemRatio float64 `name:"block-ids-mem-ratio"`
BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"`
ClientMemRatio float64 `name:"client-mem-ratio"`
EmojiMemRatio float64 `name:"emoji-mem-ratio"`
@@ -233,6 +233,8 @@ type CacheConfiguration struct {
TokenMemRatio float64 `name:"token-mem-ratio"`
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
UserMemRatio float64 `name:"user-mem-ratio"`
+ UserMuteMemRatio float64 `name:"user-mute-mem-ratio"`
+ UserMuteIDsMemRatio float64 `name:"user-mute-ids-mem-ratio"`
WebfingerMemRatio float64 `name:"webfinger-mem-ratio"`
VisibilityMemRatio float64 `name:"visibility-mem-ratio"`
}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 3410dc5e4..8a76cc21a 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -197,6 +197,8 @@ var Defaults = Configuration{
TokenMemRatio: 0.75,
TombstoneMemRatio: 0.5,
UserMemRatio: 0.25,
+ UserMuteMemRatio: 2,
+ UserMuteIDsMemRatio: 3,
WebfingerMemRatio: 0.1,
VisibilityMemRatio: 2,
},
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 2f37cbacb..edfe96e57 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -2,7 +2,7 @@
// 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
@@ -2917,7 +2917,7 @@ func (st *ConfigState) SetCacheBlockIDsMemRatio(v float64) {
}
// CacheBlockIDsMemRatioFlag returns the flag name for the 'Cache.BlockIDsMemRatio' field
-func CacheBlockIDsMemRatioFlag() string { return "cache-block-mem-ratio" }
+func CacheBlockIDsMemRatioFlag() string { return "cache-block-ids-mem-ratio" }
// GetCacheBlockIDsMemRatio safely fetches the value for global configuration 'Cache.BlockIDsMemRatio' field
func GetCacheBlockIDsMemRatio() float64 { return global.GetCacheBlockIDsMemRatio() }
@@ -3775,6 +3775,56 @@ func GetCacheUserMemRatio() float64 { return global.GetCacheUserMemRatio() }
// SetCacheUserMemRatio safely sets the value for global configuration 'Cache.UserMemRatio' field
func SetCacheUserMemRatio(v float64) { global.SetCacheUserMemRatio(v) }
+// GetCacheUserMuteMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteMemRatio' field
+func (st *ConfigState) GetCacheUserMuteMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.UserMuteMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheUserMuteMemRatio safely sets the Configuration value for state's 'Cache.UserMuteMemRatio' field
+func (st *ConfigState) SetCacheUserMuteMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.UserMuteMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheUserMuteMemRatioFlag returns the flag name for the 'Cache.UserMuteMemRatio' field
+func CacheUserMuteMemRatioFlag() string { return "cache-user-mute-mem-ratio" }
+
+// GetCacheUserMuteMemRatio safely fetches the value for global configuration 'Cache.UserMuteMemRatio' field
+func GetCacheUserMuteMemRatio() float64 { return global.GetCacheUserMuteMemRatio() }
+
+// SetCacheUserMuteMemRatio safely sets the value for global configuration 'Cache.UserMuteMemRatio' field
+func SetCacheUserMuteMemRatio(v float64) { global.SetCacheUserMuteMemRatio(v) }
+
+// GetCacheUserMuteIDsMemRatio safely fetches the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field
+func (st *ConfigState) GetCacheUserMuteIDsMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.UserMuteIDsMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheUserMuteIDsMemRatio safely sets the Configuration value for state's 'Cache.UserMuteIDsMemRatio' field
+func (st *ConfigState) SetCacheUserMuteIDsMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.UserMuteIDsMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheUserMuteIDsMemRatioFlag returns the flag name for the 'Cache.UserMuteIDsMemRatio' field
+func CacheUserMuteIDsMemRatioFlag() string { return "cache-user-mute-ids-mem-ratio" }
+
+// GetCacheUserMuteIDsMemRatio safely fetches the value for global configuration 'Cache.UserMuteIDsMemRatio' field
+func GetCacheUserMuteIDsMemRatio() float64 { return global.GetCacheUserMuteIDsMemRatio() }
+
+// SetCacheUserMuteIDsMemRatio safely sets the value for global configuration 'Cache.UserMuteIDsMemRatio' field
+func SetCacheUserMuteIDsMemRatio(v float64) { global.SetCacheUserMuteIDsMemRatio(v) }
+
// GetCacheWebfingerMemRatio safely fetches the Configuration value for state's 'Cache.WebfingerMemRatio' field
func (st *ConfigState) GetCacheWebfingerMemRatio() (v float64) {
st.mutex.RLock()
@@ -4024,3 +4074,4 @@ func GetRequestIDHeader() string { return global.GetRequestIDHeader() }
// SetRequestIDHeader safely sets the value for global configuration 'RequestIDHeader' field
func SetRequestIDHeader(v string) { global.SetRequestIDHeader(v) }
+
diff --git a/internal/db/bundb/migrations/20240528071620_add_user_mutes.go b/internal/db/bundb/migrations/20240528071620_add_user_mutes.go
new file mode 100644
index 000000000..e92e4df5b
--- /dev/null
+++ b/internal/db/bundb/migrations/20240528071620_add_user_mutes.go
@@ -0,0 +1,61 @@
+// 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 migrations
+
+import (
+ "context"
+
+ gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ if _, err := tx.
+ NewCreateTable().
+ Model(&gtsmodel.UserMute{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ if _, err := tx.
+ NewCreateIndex().
+ Table("user_mutes").
+ Index("user_mutes_account_id_idx").
+ Column("account_id").
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go
index 052f29cb3..cb820d5c4 100644
--- a/internal/db/bundb/relationship.go
+++ b/internal/db/bundb/relationship.go
@@ -20,6 +20,7 @@ package bundb
import (
"context"
"errors"
+ "time"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
@@ -108,6 +109,16 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount
rel.Note = note.Comment
}
+ // check if the requesting account is muting the target account
+ mute, err := r.GetMute(ctx, requestingAccount, targetAccount)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.Newf("error checking muting: %w", err)
+ }
+ if mute != nil && !mute.Expired(time.Now()) {
+ rel.Muting = true
+ rel.MutingNotifications = *mute.Notifications
+ }
+
return &rel, nil
}
diff --git a/internal/db/bundb/relationship_mute.go b/internal/db/bundb/relationship_mute.go
new file mode 100644
index 000000000..3c664cbd7
--- /dev/null
+++ b/internal/db/bundb/relationship_mute.go
@@ -0,0 +1,306 @@
+// 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 bundb
+
+import (
+ "context"
+ "errors"
+ "slices"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect"
+)
+
+func (r *relationshipDB) IsMuted(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error) {
+ mute, err := r.GetMute(
+ gtscontext.SetBarebones(ctx),
+ sourceAccountID,
+ targetAccountID,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return false, err
+ }
+ return mute != nil, nil
+}
+
+func (r *relationshipDB) GetMuteByID(ctx context.Context, id string) (*gtsmodel.UserMute, error) {
+ return r.getMute(
+ ctx,
+ "ID",
+ func(mute *gtsmodel.UserMute) error {
+ return r.db.NewSelect().Model(mute).
+ Where("? = ?", bun.Ident("id"), id).
+ Scan(ctx)
+ },
+ id,
+ )
+}
+
+func (r *relationshipDB) GetMute(
+ ctx context.Context,
+ sourceAccountID string,
+ targetAccountID string,
+) (*gtsmodel.UserMute, error) {
+ return r.getMute(
+ ctx,
+ "AccountID,TargetAccountID",
+ func(mute *gtsmodel.UserMute) error {
+ return r.db.NewSelect().Model(mute).
+ Where("? = ?", bun.Ident("account_id"), sourceAccountID).
+ Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
+ Scan(ctx)
+ },
+ sourceAccountID,
+ targetAccountID,
+ )
+}
+
+func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.UserMute, error) {
+ // Load all mutes IDs via cache loader callbacks.
+ mutes, err := r.state.Caches.GTS.UserMute.LoadIDs("ID",
+ ids,
+ func(uncached []string) ([]*gtsmodel.UserMute, error) {
+ // Preallocate expected length of uncached mutes.
+ mutes := make([]*gtsmodel.UserMute, 0, len(uncached))
+
+ // Perform database query scanning
+ // the remaining (uncached) IDs.
+ if err := r.db.NewSelect().
+ Model(&mutes).
+ Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
+ Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return mutes, nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // Reorder the mutes by their
+ // IDs to ensure in correct order.
+ getID := func(b *gtsmodel.UserMute) string { return b.ID }
+ util.OrderBy(mutes, ids, getID)
+
+ if gtscontext.Barebones(ctx) {
+ // no need to fully populate.
+ return mutes, nil
+ }
+
+ // Populate all loaded mutes, removing those we fail to
+ // populate (removes needing so many nil checks everywhere).
+ mutes = slices.DeleteFunc(mutes, func(mute *gtsmodel.UserMute) bool {
+ if err := r.populateMute(ctx, mute); err != nil {
+ log.Errorf(ctx, "error populating mute %s: %v", mute.ID, err)
+ return true
+ }
+ return false
+ })
+
+ return mutes, nil
+}
+
+func (r *relationshipDB) getMute(
+ ctx context.Context,
+ lookup string,
+ dbQuery func(*gtsmodel.UserMute) error,
+ keyParts ...any,
+) (*gtsmodel.UserMute, error) {
+ // Fetch mute from cache with loader callback
+ mute, err := r.state.Caches.GTS.UserMute.LoadOne(lookup, func() (*gtsmodel.UserMute, error) {
+ var mute gtsmodel.UserMute
+
+ // Not cached! Perform database query
+ if err := dbQuery(&mute); err != nil {
+ return nil, err
+ }
+
+ return &mute, nil
+ }, keyParts...)
+ if err != nil {
+ // already processe
+ return nil, err
+ }
+
+ if gtscontext.Barebones(ctx) {
+ // Only a barebones model was requested.
+ return mute, nil
+ }
+
+ if err := r.populateMute(ctx, mute); err != nil {
+ return nil, err
+ }
+
+ return mute, nil
+}
+
+func (r *relationshipDB) populateMute(ctx context.Context, mute *gtsmodel.UserMute) error {
+ var (
+ errs gtserror.MultiError
+ err error
+ )
+
+ if mute.Account == nil {
+ // Mute origin account is not set, fetch from database.
+ mute.Account, err = r.state.DB.GetAccountByID(
+ gtscontext.SetBarebones(ctx),
+ mute.AccountID,
+ )
+ if err != nil {
+ errs.Appendf("error populating mute account: %w", err)
+ }
+ }
+
+ if mute.TargetAccount == nil {
+ // Mute target account is not set, fetch from database.
+ mute.TargetAccount, err = r.state.DB.GetAccountByID(
+ gtscontext.SetBarebones(ctx),
+ mute.TargetAccountID,
+ )
+ if err != nil {
+ errs.Appendf("error populating mute target account: %w", err)
+ }
+ }
+
+ return errs.Combine()
+}
+
+func (r *relationshipDB) PutMute(ctx context.Context, mute *gtsmodel.UserMute) error {
+ return r.state.Caches.GTS.UserMute.Store(mute, func() error {
+ _, err := NewUpsert(r.db).Model(mute).Constraint("id").Exec(ctx)
+ return err
+ })
+}
+
+func (r *relationshipDB) DeleteMuteByID(ctx context.Context, id string) error {
+ // Load mute into cache before attempting a delete,
+ // as we need it cached in order to trigger the invalidate
+ // callback. This in turn invalidates others.
+ _, err := r.GetMuteByID(gtscontext.SetBarebones(ctx), id)
+ if err != nil {
+ if errors.Is(err, db.ErrNoEntries) {
+ // not an issue.
+ err = nil
+ }
+ return err
+ }
+
+ // Drop this now-cached mute on return after delete.
+ defer r.state.Caches.GTS.UserMute.Invalidate("ID", id)
+
+ // Finally delete mute from DB.
+ _, err = r.db.NewDelete().
+ Table("user_mutes").
+ Where("? = ?", bun.Ident("id"), id).
+ Exec(ctx)
+ return err
+}
+
+func (r *relationshipDB) DeleteAccountMutes(ctx context.Context, accountID string) error {
+ var muteIDs []string
+
+ // Get full list of IDs.
+ if err := r.db.NewSelect().
+ Column("id").
+ Table("user_mutes").
+ WhereOr("? = ? OR ? = ?",
+ bun.Ident("account_id"),
+ accountID,
+ bun.Ident("target_account_id"),
+ accountID,
+ ).
+ Scan(ctx, &muteIDs); err != nil {
+ return err
+ }
+
+ defer func() {
+ // Invalidate all account's incoming / outoing mutes on return.
+ r.state.Caches.GTS.UserMute.Invalidate("AccountID", accountID)
+ r.state.Caches.GTS.UserMute.Invalidate("TargetAccountID", accountID)
+ }()
+
+ // Load all mutes into cache, this *really* isn't great
+ // but it is the only way we can ensure we invalidate all
+ // related caches correctly (e.g. visibility).
+ _, err := r.GetAccountMutes(ctx, accountID, nil)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return err
+ }
+
+ // Finally delete all from DB.
+ _, err = r.db.NewDelete().
+ Table("user_mutes").
+ Where("? IN (?)", bun.Ident("id"), bun.In(muteIDs)).
+ Exec(ctx)
+ return err
+}
+
+func (r *relationshipDB) GetAccountMutes(
+ ctx context.Context,
+ accountID string,
+ page *paging.Page,
+) ([]*gtsmodel.UserMute, error) {
+ muteIDs, err := r.getAccountMuteIDs(ctx, accountID, page)
+ if err != nil {
+ return nil, err
+ }
+ return r.getMutesByIDs(ctx, muteIDs)
+}
+
+func (r *relationshipDB) getAccountMuteIDs(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
+ return loadPagedIDs(&r.state.Caches.GTS.UserMuteIDs, accountID, page, func() ([]string, error) {
+ var muteIDs []string
+
+ // Mute IDs not in cache. Perform DB query.
+ if _, err := r.db.
+ NewSelect().
+ TableExpr("?", bun.Ident("user_mutes")).
+ ColumnExpr("?", bun.Ident("id")).
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
+ var notYetExpiredSQL string
+ switch r.db.Dialect().Name() {
+ case dialect.SQLite:
+ notYetExpiredSQL = "? > DATE('now')"
+ case dialect.PG:
+ notYetExpiredSQL = "? > NOW()"
+ default:
+ log.Panicf(nil, "db conn %s was neither pg nor sqlite", r.db)
+ }
+ return q.
+ Where("? IS NULL", bun.Ident("expires_at")).
+ WhereOr(notYetExpiredSQL, bun.Ident("expires_at"))
+ }).
+ OrderExpr("? DESC", bun.Ident("id")).
+ Exec(ctx, &muteIDs); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, err
+ }
+
+ return muteIDs, nil
+ })
+}
diff --git a/internal/db/bundb/relationship_test.go b/internal/db/bundb/relationship_test.go
index f1d1a35d2..46a4f1f25 100644
--- a/internal/db/bundb/relationship_test.go
+++ b/internal/db/bundb/relationship_test.go
@@ -510,6 +510,43 @@ func (suite *RelationshipTestSuite) TestDeleteAccountBlocks() {
suite.Nil(block)
}
+func (suite *RelationshipTestSuite) TestDeleteAccountMutes() {
+ ctx := context.Background()
+
+ // Add a mute.
+ accountID1 := suite.testAccounts["local_account_1"].ID
+ accountID2 := suite.testAccounts["local_account_2"].ID
+ muteID := "01HZGZ3F3C7S1TTPE8F9VPZDCB"
+ err := suite.db.PutMute(ctx, &gtsmodel.UserMute{
+ ID: muteID,
+ AccountID: accountID1,
+ TargetAccountID: accountID2,
+ })
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Make sure the mute is in the DB.
+ mute, err := suite.db.GetMute(ctx, accountID1, accountID2)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ if suite.NotNil(mute) {
+ suite.Equal(muteID, mute.ID)
+ }
+
+ // Delete all mutes owned by that account.
+ err = suite.db.DeleteAccountMutes(ctx, accountID1)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Mute should be gone.
+ mute, err = suite.db.GetMute(ctx, accountID1, accountID2)
+ suite.ErrorIs(err, db.ErrNoEntries)
+ suite.Nil(mute)
+}
+
func (suite *RelationshipTestSuite) TestGetRelationship() {
requestingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["admin_account"]
diff --git a/internal/db/relationship.go b/internal/db/relationship.go
index cd4539791..5e0650fb7 100644
--- a/internal/db/relationship.go
+++ b/internal/db/relationship.go
@@ -187,4 +187,25 @@ type Relationship interface {
// PopulateNote populates the struct pointers on the given note.
PopulateNote(ctx context.Context, note *gtsmodel.AccountNote) error
+
+ // IsMuted checks whether source account has a mute in place against target.
+ IsMuted(ctx context.Context, sourceAccountID string, targetAccountID string) (bool, error)
+
+ // GetMuteByID fetches mute with given ID from the database.
+ GetMuteByID(ctx context.Context, id string) (*gtsmodel.UserMute, error)
+
+ // GetMute returns the mute from account1 targeting account2, if it exists, or an error if it doesn't.
+ GetMute(ctx context.Context, account1 string, account2 string) (*gtsmodel.UserMute, error)
+
+ // PutMute attempts to insert or update the given account mute in the database.
+ PutMute(ctx context.Context, mute *gtsmodel.UserMute) error
+
+ // DeleteMuteByID removes mute with given ID from the database.
+ DeleteMuteByID(ctx context.Context, id string) error
+
+ // DeleteAccountMutes will delete all database mutes to / from the given account ID.
+ DeleteAccountMutes(ctx context.Context, accountID string) error
+
+ // GetAccountMutes returns all mutes originating from the given account, with given optional paging parameters.
+ GetAccountMutes(ctx context.Context, accountID string, paging *paging.Page) ([]*gtsmodel.UserMute, error)
}
diff --git a/internal/filter/usermute/usermute.go b/internal/filter/usermute/usermute.go
new file mode 100644
index 000000000..6d710e995
--- /dev/null
+++ b/internal/filter/usermute/usermute.go
@@ -0,0 +1,80 @@
+// 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 usermute
+
+import (
+ "time"
+
+ statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+type compiledUserMuteListEntry struct {
+ ExpiresAt time.Time
+ Notifications bool
+}
+
+func (e *compiledUserMuteListEntry) appliesInContext(filterContext statusfilter.FilterContext) bool {
+ switch filterContext {
+ case statusfilter.FilterContextHome:
+ return true
+ case statusfilter.FilterContextNotifications:
+ return e.Notifications
+ case statusfilter.FilterContextPublic:
+ return true
+ case statusfilter.FilterContextThread:
+ return true
+ case statusfilter.FilterContextAccount:
+ return false
+ }
+ return false
+}
+
+func (e *compiledUserMuteListEntry) expired(now time.Time) bool {
+ return !e.ExpiresAt.IsZero() && !e.ExpiresAt.After(now)
+}
+
+type CompiledUserMuteList struct {
+ byTargetAccountID map[string]compiledUserMuteListEntry
+}
+
+func NewCompiledUserMuteList(mutes []*gtsmodel.UserMute) (c *CompiledUserMuteList) {
+ c = &CompiledUserMuteList{byTargetAccountID: make(map[string]compiledUserMuteListEntry, len(mutes))}
+ for _, mute := range mutes {
+ c.byTargetAccountID[mute.TargetAccountID] = compiledUserMuteListEntry{
+ ExpiresAt: mute.ExpiresAt,
+ Notifications: *mute.Notifications,
+ }
+ }
+ return
+}
+
+func (c *CompiledUserMuteList) Len() int {
+ if c == nil {
+ return 0
+ }
+ return len(c.byTargetAccountID)
+}
+
+func (c *CompiledUserMuteList) Matches(accountID string, filterContext statusfilter.FilterContext, now time.Time) bool {
+ if c == nil {
+ return false
+ }
+ e, found := c.byTargetAccountID[accountID]
+ return found && e.appliesInContext(filterContext) && !e.expired(now)
+}
diff --git a/internal/gtsmodel/usermute.go b/internal/gtsmodel/usermute.go
new file mode 100644
index 000000000..5ee003d89
--- /dev/null
+++ b/internal/gtsmodel/usermute.go
@@ -0,0 +1,41 @@
+// 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 gtsmodel
+
+import (
+ "time"
+)
+
+// UserMute refers to the muting of one account by another.
+type UserMute struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
+ UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
+ ExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // Time mute should expire. If null, should not expire.
+ AccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who does this mute originate from?
+ Account *Account `bun:"-"` // Account corresponding to accountID
+ TargetAccountID string `bun:"type:CHAR(26),unique:user_mutes_account_id_target_account_id_uniq,notnull,nullzero"` // Who is the target of this mute?
+ TargetAccount *Account `bun:"-"` // Account corresponding to targetAccountID
+ Notifications *bool `bun:",nullzero,notnull,default:false"` // Apply mute to notifications as well as statuses.
+}
+
+// Expired returns whether the mute has expired at a given time.
+// Mutes without an expiration timestamp never expire.
+func (u *UserMute) Expired(now time.Time) bool {
+ return !u.ExpiresAt.IsZero() && !u.ExpiresAt.After(now)
+}
diff --git a/internal/processing/account/bookmarks.go b/internal/processing/account/bookmarks.go
index 5618934ae..b9ecf0217 100644
--- a/internal/processing/account/bookmarks.go
+++ b/internal/processing/account/bookmarks.go
@@ -75,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode
}
// Convert the status.
- item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil)
+ item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil)
if err != nil {
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
continue
diff --git a/internal/processing/account/mute.go b/internal/processing/account/mute.go
new file mode 100644
index 000000000..00bb9dd22
--- /dev/null
+++ b/internal/processing/account/mute.go
@@ -0,0 +1,198 @@
+// 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 account
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// MuteCreate handles the creation or updating of a mute from requestingAccount to targetAccountID.
+// The form params should have already been normalized by the time they reach this function.
+func (p *Processor) MuteCreate(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetAccountID string,
+ form *apimodel.UserMuteCreateUpdateRequest,
+) (*apimodel.Relationship, gtserror.WithCode) {
+ targetAccount, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ if existingMute != nil &&
+ *existingMute.Notifications == *form.Notifications &&
+ existingMute.ExpiresAt.IsZero() && form.Duration == nil {
+ // Mute already exists and doesn't require updating, nothing to do.
+ return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
+ }
+
+ // Create a new mute or update an existing one.
+ mute := &gtsmodel.UserMute{
+ AccountID: requestingAccount.ID,
+ Account: requestingAccount,
+ TargetAccountID: targetAccountID,
+ TargetAccount: targetAccount,
+ Notifications: form.Notifications,
+ }
+ if existingMute != nil {
+ mute.ID = existingMute.ID
+ } else {
+ mute.ID = id.NewULID()
+ }
+ if form.Duration != nil {
+ mute.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.Duration))
+ }
+
+ if err := p.state.DB.PutMute(ctx, mute); err != nil {
+ err = gtserror.Newf("error creating or updating mute in db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
+}
+
+// MuteRemove handles the removal of a mute from requestingAccount to targetAccountID.
+func (p *Processor) MuteRemove(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetAccountID string,
+) (*apimodel.Relationship, gtserror.WithCode) {
+ _, existingMute, errWithCode := p.getMuteTarget(ctx, requestingAccount, targetAccountID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ if existingMute == nil {
+ // Already not muted, nothing to do.
+ return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
+ }
+
+ // We got a mute, remove it from the db.
+ if err := p.state.DB.DeleteMuteByID(ctx, existingMute.ID); err != nil {
+ err := gtserror.Newf("error removing mute from db: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.RelationshipGet(ctx, requestingAccount, targetAccountID)
+}
+
+// MutesGet retrieves the user's list of muted accounts, with an extra field for mute expiration (if applicable).
+func (p *Processor) MutesGet(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ page *paging.Page,
+) (*apimodel.PageableResponse, gtserror.WithCode) {
+ mutes, err := p.state.DB.GetAccountMutes(ctx,
+ requestingAccount.ID,
+ page,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("couldn't list account's mutes: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Check for empty response.
+ count := len(mutes)
+ if len(mutes) == 0 {
+ return util.EmptyPageableResponse(), nil
+ }
+
+ // Get the lowest and highest
+ // ID values, used for paging.
+ lo := mutes[count-1].ID
+ hi := mutes[0].ID
+
+ items := make([]interface{}, 0, count)
+
+ now := time.Now()
+ for _, mute := range mutes {
+ // Skip accounts for which the mute has expired.
+ if mute.Expired(now) {
+ continue
+ }
+
+ // Convert target account to frontend API model. (target will never be nil)
+ account, err := p.converter.AccountToAPIAccountPublic(ctx, mute.TargetAccount)
+ if err != nil {
+ log.Errorf(ctx, "error converting account to public api account: %v", err)
+ continue
+ }
+ mutedAccount := &apimodel.MutedAccount{
+ Account: *account,
+ }
+ // Add the mute expiration field (unique to this API).
+ if !mute.ExpiresAt.IsZero() {
+ mutedAccount.MuteExpiresAt = util.Ptr(util.FormatISO8601(mute.ExpiresAt))
+ }
+
+ // Append target to return items.
+ items = append(items, mutedAccount)
+ }
+
+ return paging.PackageResponse(paging.ResponseParams{
+ Items: items,
+ Path: "/api/v1/mutes",
+ Next: page.Next(lo, hi),
+ Prev: page.Prev(lo, hi),
+ }), nil
+}
+
+func (p *Processor) getMuteTarget(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetAccountID string,
+) (*gtsmodel.Account, *gtsmodel.UserMute, gtserror.WithCode) {
+ // Account should not mute or unmute itself.
+ if requestingAccount.ID == targetAccountID {
+ err := gtserror.Newf("account %s cannot mute or unmute itself", requestingAccount.ID)
+ return nil, nil, gtserror.NewErrorNotAcceptable(err, err.Error())
+ }
+
+ // Ensure target account retrievable.
+ targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
+ if err != nil {
+ if !errors.Is(err, db.ErrNoEntries) {
+ // Real db error.
+ err = gtserror.Newf("db error looking for target account %s: %w", targetAccountID, err)
+ return nil, nil, gtserror.NewErrorInternalError(err)
+ }
+ // Account not found.
+ err = gtserror.Newf("target account %s not found in the db", targetAccountID)
+ return nil, nil, gtserror.NewErrorNotFound(err, err.Error())
+ }
+
+ // Check if currently muted.
+ mute, err := p.state.DB.GetMute(ctx, requestingAccount.ID, targetAccountID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = gtserror.Newf("db error checking existing mute: %w", err)
+ return nil, nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return targetAccount, mute, nil
+}
diff --git a/internal/processing/account/statuses.go b/internal/processing/account/statuses.go
index 8f0548371..2513f17c7 100644
--- a/internal/processing/account/statuses.go
+++ b/internal/processing/account/statuses.go
@@ -105,7 +105,7 @@ func (p *Processor) StatusesGet(
for _, s := range filtered {
// Convert filtered statuses to API statuses.
- item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters)
+ item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextAccount, filters, nil)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue
diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go
index bb46ee38c..2ffc90035 100644
--- a/internal/processing/common/status.go
+++ b/internal/processing/common/status.go
@@ -185,7 +185,7 @@ func (p *Processor) GetAPIStatus(
apiStatus *apimodel.Status,
errWithCode gtserror.WithCode,
) {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil, nil)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go
index 196fef5fc..190289155 100644
--- a/internal/processing/search/util.go
+++ b/internal/processing/search/util.go
@@ -114,7 +114,7 @@ func (p *Processor) packageStatuses(
continue
}
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextNone, nil, nil)
if err != nil {
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
continue
diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go
index c05f3effd..16f55b439 100644
--- a/internal/processing/status/get.go
+++ b/internal/processing/status/get.go
@@ -24,6 +24,8 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
@@ -286,8 +288,16 @@ func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
+
+ mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
- return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters)
+ return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextThread, filters, compiledMutes)
}
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
}
diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go
index 12971caa1..359212ee6 100644
--- a/internal/processing/stream/statusupdate_test.go
+++ b/internal/processing/stream/statusupdate_test.go
@@ -40,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.NoError(errWithCode)
editedStatus := suite.testStatuses["remote_account_1_status_1"]
- apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil)
+ apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, statusfilter.FilterContextNotifications, nil, nil)
suite.NoError(err)
suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)
diff --git a/internal/processing/timeline/faved.go b/internal/processing/timeline/faved.go
index c3b0e1837..cd3729465 100644
--- a/internal/processing/timeline/faved.go
+++ b/internal/processing/timeline/faved.go
@@ -55,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, statusfilter.FilterContextNone, nil, nil)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue
diff --git a/internal/processing/timeline/home.go b/internal/processing/timeline/home.go
index e174b3428..8bf8dd428 100644
--- a/internal/processing/timeline/home.go
+++ b/internal/processing/timeline/home.go
@@ -24,7 +24,9 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@@ -105,7 +107,14 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
- return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters)
+ mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
+ return nil, err
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
+ return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
diff --git a/internal/processing/timeline/list.go b/internal/processing/timeline/list.go
index 60cdbac7a..2065256e3 100644
--- a/internal/processing/timeline/list.go
+++ b/internal/processing/timeline/list.go
@@ -24,7 +24,9 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@@ -117,7 +119,14 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
- return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters)
+ mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
+ return nil, err
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
+ return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
diff --git a/internal/processing/timeline/notification.go b/internal/processing/timeline/notification.go
index f99664d62..5156a1cdf 100644
--- a/internal/processing/timeline/notification.go
+++ b/internal/processing/timeline/notification.go
@@ -24,6 +24,9 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -49,6 +52,13 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
return nil, gtserror.NewErrorInternalError(err)
}
+ mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), authed.Account.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", authed.Account.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
var (
items = make([]interface{}, 0, count)
nextMaxIDValue string
@@ -76,9 +86,11 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
- item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
+ item, err := p.converter.NotificationToAPINotification(ctx, n, filters, compiledMutes)
if err != nil {
- log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
+ if !errors.Is(err, status.ErrHideStatus) {
+ log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
+ }
continue
}
@@ -116,7 +128,14 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
return nil, gtserror.NewErrorInternalError(err)
}
- apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters)
+ mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), account.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", account.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
+ apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
diff --git a/internal/processing/timeline/public.go b/internal/processing/timeline/public.go
index a0e594629..28062fb2e 100644
--- a/internal/processing/timeline/public.go
+++ b/internal/processing/timeline/public.go
@@ -25,6 +25,8 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -48,6 +50,7 @@ func (p *Processor) PublicTimelineGet(
)
var filters []*gtsmodel.Filter
+ var compiledMutes *usermute.CompiledUserMuteList
if requester != nil {
var err error
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
@@ -55,6 +58,13 @@ func (p *Processor) PublicTimelineGet(
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
+
+ mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ compiledMutes = usermute.NewCompiledUserMuteList(mutes)
}
// Try a few times to select appropriate public
@@ -98,7 +108,7 @@ outer:
continue inner
}
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
diff --git a/internal/processing/timeline/tag.go b/internal/processing/timeline/tag.go
index 5308cac59..4320f6adc 100644
--- a/internal/processing/timeline/tag.go
+++ b/internal/processing/timeline/tag.go
@@ -25,6 +25,8 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
+ "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -118,6 +120,13 @@ func (p *Processor) packageTagResponse(
return nil, gtserror.NewErrorInternalError(err)
}
+ mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil)
+ if err != nil {
+ err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
for _, s := range statuses {
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
if err != nil {
@@ -129,7 +138,7 @@ func (p *Processor) packageTagResponse(
continue
}
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters, compiledMutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go
index 6a12ce043..15be23baf 100644
--- a/internal/processing/workers/fromclientapi_test.go
+++ b/internal/processing/workers/fromclientapi_test.go
@@ -157,6 +157,7 @@ func (suite *FromClientAPITestSuite) statusJSON(
requestingAccount,
statusfilter.FilterContextNone,
nil,
+ nil,
)
if err != nil {
suite.FailNow(err.Error())
@@ -261,7 +262,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
suite.FailNow("timed out waiting for new status notification")
}
- apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil)
+ apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil)
if err != nil {
suite.FailNow(err.Error())
}
diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go
index a31946cc8..edeb4b57e 100644
--- a/internal/processing/workers/surfacenotify.go
+++ b/internal/processing/workers/surfacenotify.go
@@ -23,6 +23,8 @@ import (
"strings"
"github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -472,8 +474,17 @@ func (s *Surface) Notify(
return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err)
}
- apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters)
+ mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), targetAccount.ID, nil)
if err != nil {
+ return gtserror.Newf("couldn't retrieve mutes for account %s: %w", targetAccount.ID, err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
+ apiNotif, err := s.Converter.NotificationToAPINotification(ctx, notif, filters, compiledMutes)
+ if err != nil {
+ if errors.Is(err, status.ErrHideStatus) {
+ return nil
+ }
return gtserror.Newf("error converting notification to api representation: %w", err)
}
s.Stream.Notify(ctx, targetAccount, apiNotif)
diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go
index 32fdd66e2..41d7f6f2a 100644
--- a/internal/processing/workers/surfacetimeline.go
+++ b/internal/processing/workers/surfacetimeline.go
@@ -23,6 +23,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -117,6 +118,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
+ mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil)
+ if err != nil {
+ return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusForFollow(
@@ -125,6 +132,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
follow,
&errs,
filters,
+ compiledMutes,
)
// Add status to home timeline for owner
@@ -137,6 +145,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
status,
stream.TimelineHome,
filters,
+ compiledMutes,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@@ -189,6 +198,7 @@ func (s *Surface) listTimelineStatusForFollow(
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@@ -232,6 +242,7 @@ func (s *Surface) listTimelineStatusForFollow(
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
+ mutes,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@@ -343,6 +354,7 @@ func (s *Surface) timelineStatus(
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) (bool, error) {
// Ingest status into given timeline using provided function.
if inserted, err := ingest(ctx, timelineID, status); err != nil {
@@ -359,6 +371,7 @@ func (s *Surface) timelineStatus(
account,
statusfilter.FilterContextHome,
filters,
+ mutes,
)
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
@@ -478,6 +491,12 @@ func (s *Surface) timelineStatusUpdateForFollowers(
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
+ mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil)
+ if err != nil {
+ return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusUpdateForFollow(
@@ -486,6 +505,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
follow,
&errs,
filters,
+ compiledMutes,
)
// Add status to home timeline for owner
@@ -496,6 +516,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
status,
stream.TimelineHome,
filters,
+ compiledMutes,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@@ -514,6 +535,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@@ -555,6 +577,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
+ mutes,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@@ -570,8 +593,9 @@ func (s *Surface) timelineStreamStatusUpdate(
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) error {
- apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters)
+ apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
// Don't put this status in the stream.
return nil
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
index 5f849c39d..dfa72fdcd 100644
--- a/internal/typeutils/converter.go
+++ b/internal/typeutils/converter.go
@@ -20,6 +20,7 @@ package typeutils
import (
"sync"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
@@ -27,11 +28,13 @@ type Converter struct {
state *state.State
defaultAvatars []string
randAvatars sync.Map
+ filter *visibility.Filter
}
func NewConverter(state *state.State) *Converter {
return &Converter{
state: state,
defaultAvatars: populateDefaultAvatars(),
+ filter: visibility.NewFilter(state),
}
}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index e1380fc9e..787d8f099 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -31,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language"
@@ -741,8 +742,9 @@ func (c *Converter) StatusToAPIStatus(
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) (*apimodel.Status, error) {
- apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters)
+ apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters, mutes)
if err != nil {
return nil, err
}
@@ -757,7 +759,7 @@ func (c *Converter) StatusToAPIStatus(
return apiStatus, nil
}
-// statusToAPIFilterResults applies filters to a status and returns an API filter result object.
+// statusToAPIFilterResults applies filters and mutes to a status and returns an API filter result object.
// The result may be nil if no filters matched.
// If the status should not be returned at all, it returns the ErrHideStatus error.
func (c *Converter) statusToAPIFilterResults(
@@ -766,14 +768,71 @@ func (c *Converter) statusToAPIFilterResults(
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) ([]apimodel.FilterResult, error) {
- if filterContext == "" || len(filters) == 0 || s.AccountID == requestingAccount.ID {
+ // If there are no filters or mutes, we're done.
+ // We never hide statuses authored by the requesting account,
+ // since not being able to see your own posts is confusing.
+ if filterContext == "" || (len(filters) == 0 && mutes.Len() == 0) || s.AccountID == requestingAccount.ID {
return nil, nil
}
- filterResults := make([]apimodel.FilterResult, 0, len(filters))
-
+ // Both mutes and filters can expire.
now := time.Now()
+
+ // If the requesting account mutes the account that created this status, hide the status.
+ if mutes.Matches(s.AccountID, filterContext, now) {
+ return nil, statusfilter.ErrHideStatus
+ }
+ // If this status is part of a multi-account discussion,
+ // and all of the accounts replied to or mentioned are invisible to the requesting account
+ // (due to blocks, domain blocks, moderation, etc.),
+ // or are muted, hide the status.
+ // First, collect the accounts we have to check.
+ otherAccounts := make([]*gtsmodel.Account, 0, 1+len(s.Mentions))
+ if s.InReplyToAccount != nil {
+ otherAccounts = append(otherAccounts, s.InReplyToAccount)
+ }
+ for _, mention := range s.Mentions {
+ otherAccounts = append(otherAccounts, mention.TargetAccount)
+ }
+ // If there are no other accounts, skip this check.
+ if len(otherAccounts) > 0 {
+ // Start by assuming that they're all invisible or muted.
+ allOtherAccountsInvisibleOrMuted := true
+
+ for _, account := range otherAccounts {
+ // Is this account visible?
+ visible, err := c.filter.AccountVisible(ctx, requestingAccount, account)
+ if err != nil {
+ return nil, err
+ }
+ if !visible {
+ // It's invisible. Check the next account.
+ continue
+ }
+
+ // If visible, is it muted?
+ if mutes.Matches(account.ID, filterContext, now) {
+ // It's muted. Check the next account.
+ continue
+ }
+
+ // If we get here, the account is visible and not muted.
+ // We should show this status, and don't have to check any more accounts.
+ allOtherAccountsInvisibleOrMuted = false
+ break
+ }
+
+ // If we didn't find any visible non-muted accounts, hide the status.
+ if allOtherAccountsInvisibleOrMuted {
+ return nil, statusfilter.ErrHideStatus
+ }
+ }
+
+ // At this point, the status isn't muted, but might still be filtered.
+ // Record all matching warn filters and the reasons they matched.
+ filterResults := make([]apimodel.FilterResult, 0, len(filters))
for _, filter := range filters {
if !filterAppliesInContext(filter, filterContext) {
// Filter doesn't apply to this context.
@@ -893,7 +952,7 @@ func (c *Converter) StatusToWebStatus(
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) {
- webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil)
+ webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
if err != nil {
return nil, err
}
@@ -997,6 +1056,7 @@ func (c *Converter) statusToFrontend(
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
) (*apimodel.Status, error) {
// Try to populate status struct pointer fields.
// We can continue in many cases of partial failure,
@@ -1095,7 +1155,7 @@ func (c *Converter) statusToFrontend(
}
if s.BoostOf != nil {
- reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters)
+ reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters, mutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
// If we'd hide the original status, hide the boost.
return nil, err
@@ -1164,8 +1224,11 @@ func (c *Converter) statusToFrontend(
}
// Apply filters.
- filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters)
+ filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters, mutes)
if err != nil {
+ if errors.Is(err, statusfilter.ErrHideStatus) {
+ return nil, err
+ }
return nil, fmt.Errorf("error applying filters: %w", err)
}
apiStatus.Filtered = filterResults
@@ -1453,7 +1516,12 @@ func (c *Converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod
}
// NotificationToAPINotification converts a gts notification into a api notification
-func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter) (*apimodel.Notification, error) {
+func (c *Converter) NotificationToAPINotification(
+ ctx context.Context,
+ n *gtsmodel.Notification,
+ filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
+) (*apimodel.Notification, error) {
if n.TargetAccount == nil {
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
if err != nil {
@@ -1494,8 +1562,11 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
}
var err error
- apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters)
+ apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, statusfilter.FilterContextNotifications, filters, mutes)
if err != nil {
+ if errors.Is(err, statusfilter.ErrHideStatus) {
+ return nil, err
+ }
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err)
}
}
@@ -1647,7 +1718,7 @@ 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)
+ status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index 946e38b30..16dc27c87 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -26,7 +26,9 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -428,7 +430,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_1"]
- apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@@ -556,6 +558,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
requestingAccount,
statusfilter.FilterContextHome,
requestingAccountFilters,
+ nil,
)
suite.NoError(err)
@@ -711,6 +714,60 @@ func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() {
requestingAccount,
statusfilter.FilterContextHome,
requestingAccountFilters,
+ nil,
+ )
+ suite.ErrorIs(err, statusfilter.ErrHideStatus)
+}
+
+// Test that a status from a user muted by the requesting user results in the ErrHideStatus error.
+func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
+ testStatus := suite.testStatuses["admin_account_status_1"]
+ requestingAccount := suite.testAccounts["local_account_1"]
+ mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
+ {
+ AccountID: requestingAccount.ID,
+ TargetAccountID: testStatus.AccountID,
+ Notifications: util.Ptr(false),
+ },
+ })
+ _, err := suite.typeconverter.StatusToAPIStatus(
+ context.Background(),
+ testStatus,
+ requestingAccount,
+ statusfilter.FilterContextHome,
+ nil,
+ mutes,
+ )
+ suite.ErrorIs(err, statusfilter.ErrHideStatus)
+}
+
+// Test that a status replying to a user muted by the requesting user results in the ErrHideStatus error.
+func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() {
+ mutedAccount := suite.testAccounts["local_account_2"]
+ testStatus := suite.testStatuses["admin_account_status_1"]
+ testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID
+ testStatus.InReplyToAccountID = mutedAccount.ID
+ requestingAccount := suite.testAccounts["local_account_1"]
+ mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
+ {
+ AccountID: requestingAccount.ID,
+ TargetAccountID: mutedAccount.ID,
+ Notifications: util.Ptr(false),
+ },
+ })
+ // Populate status so the converter has the account objects it needs for muting.
+ err := suite.db.PopulateStatus(context.Background(), testStatus)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ // Convert the status to API format, which should fail.
+ _, err = suite.typeconverter.StatusToAPIStatus(
+ context.Background(),
+ testStatus,
+ requestingAccount,
+ statusfilter.FilterContextHome,
+ nil,
+ mutes,
)
suite.ErrorIs(err, statusfilter.ErrHideStatus)
}
@@ -719,7 +776,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
testStatus := suite.testStatuses["remote_account_2_status_1"]
requestingAccount := suite.testAccounts["admin_account"]
- apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@@ -952,7 +1009,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
*testStatus = *suite.testStatuses["admin_account_status_1"]
testStatus.Language = ""
requestingAccount := suite.testAccounts["local_account_1"]
- apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil)
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")