diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 120513a0c..792714a39 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -6527,6 +6527,44 @@ paths:
summary: View accounts that have faved/starred/liked the target status.
tags:
- statuses
+ /api/v1/statuses/{id}/mute:
+ post:
+ description: |-
+ Target status must belong to you or mention you.
+
+ Status thread mutes and unmutes are idempotent. If you already mute a thread, muting it again just means it stays muted and you'll get 200 OK back.
+ operationId: statusMute
+ parameters:
+ - description: Target status ID.
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: The now-muted status.
+ schema:
+ $ref: '#/definitions/status'
+ "400":
+ description: bad request; you're not part of the target status thread
+ "401":
+ description: unauthorized
+ "403":
+ description: forbidden
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:mutes
+ summary: Mute a status's thread. This prevents notifications from being created for future replies, likes, boosts etc in the thread of which the target status is a part.
+ tags:
+ - statuses
/api/v1/statuses/{id}/pin:
post:
description: |-
@@ -6703,6 +6741,44 @@ paths:
summary: Unstar/unlike/unfavourite the given status.
tags:
- statuses
+ /api/v1/statuses/{id}/unmute:
+ post:
+ description: |-
+ Target status must belong to you or mention you.
+
+ Status thread mutes and unmutes are idempotent. If you already unmuted a thread, unmuting it again just means it stays unmuted and you'll get 200 OK back.
+ operationId: statusUnmute
+ parameters:
+ - description: Target status ID.
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: The now-unmuted status.
+ schema:
+ $ref: '#/definitions/status'
+ "400":
+ description: bad request; you're not part of the target status thread
+ "401":
+ description: unauthorized
+ "403":
+ description: forbidden
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:mutes
+ summary: Unmute a status's thread. This reenables notifications for future replies, likes, boosts etc in the thread of which the target status is a part.
+ tags:
+ - statuses
/api/v1/statuses/{id}/unpin:
post:
operationId: statusUnpin
@@ -7336,6 +7412,7 @@ securityDefinitions:
read:follows: grant read access to follows
read:lists: grant read access to lists
read:media: grant read access to media
+ read:mutes: grant read access to mutes
read:notifications: grants read access to notifications
read:search: grant read access to searches
read:statuses: grants read access to statuses
@@ -7347,6 +7424,7 @@ securityDefinitions:
write:follows: grants write access to follows
write:lists: grants write access to lists
write:media: grants write access to media
+ write:mutes: grants write access to mutes
write:statuses: grants write access to statuses
write:user: grants write access to user-level info
tokenUrl: https://example.org/oauth/token
diff --git a/docs/swagger.go b/docs/swagger.go
index a65b4bf40..8f64bcc42 100644
--- a/docs/swagger.go
+++ b/docs/swagger.go
@@ -39,6 +39,7 @@
// read:follows: grant read access to follows
// read:lists: grant read access to lists
// read:media: grant read access to media
+// read:mutes: grant read access to mutes
// read:search: grant read access to searches
// read:statuses: grants read access to statuses
// read:streaming: grants read access to streaming api
@@ -50,6 +51,7 @@
// write:follows: grants write access to follows
// write:lists: grants write access to lists
// write:media: grants write access to media
+// write:mutes: grants write access to mutes
// write:statuses: grants write access to statuses
// write:user: grants write access to user-level info
// admin: grants admin access to everything
diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go
index d12665a9d..c93d69994 100644
--- a/internal/api/client/statuses/status.go
+++ b/internal/api/client/statuses/status.go
@@ -91,6 +91,10 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPost, PinPath, m.StatusPinPOSTHandler)
attachHandler(http.MethodPost, UnpinPath, m.StatusUnpinPOSTHandler)
+ // mute stuff
+ attachHandler(http.MethodPost, MutePath, m.StatusMutePOSTHandler)
+ attachHandler(http.MethodPost, UnmutePath, m.StatusUnmutePOSTHandler)
+
// reblog stuff
attachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler)
attachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler)
diff --git a/internal/api/client/statuses/statusmute.go b/internal/api/client/statuses/statusmute.go
new file mode 100644
index 000000000..95ada8939
--- /dev/null
+++ b/internal/api/client/statuses/statusmute.go
@@ -0,0 +1,99 @@
+// 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 .
+
+package statuses
+
+import (
+ "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"
+)
+
+// StatusMutePOSTHandler swagger:operation POST /api/v1/statuses/{id}/mute statusMute
+//
+// Mute a status's thread. This prevents notifications from being created for future replies, likes, boosts etc in the thread of which the target status is a part.
+//
+// Target status must belong to you or mention you.
+//
+// Status thread mutes and unmutes are idempotent. If you already mute a thread, muting it again just means it stays muted and you'll get 200 OK back.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:mutes
+//
+// responses:
+// '200':
+// name: status
+// description: The now-muted status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request; you're not part of the target status thread
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusMutePOSTHandler(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
+ }
+
+ targetStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.Status().MuteCreate(c.Request.Context(), authed.Account, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go
new file mode 100644
index 000000000..e642fb308
--- /dev/null
+++ b/internal/api/client/statuses/statusmute_test.go
@@ -0,0 +1,217 @@
+// 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 .
+
+package statuses_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type StatusMuteTestSuite struct {
+ StatusStandardTestSuite
+}
+
+func (suite *StatusMuteTestSuite) post(path string, handler func(*gin.Context), targetStatusID string) (int, string) {
+ t := suite.testTokens["local_account_1"]
+ oauthToken := oauth.DBTokenToToken(t)
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
+ ctx.Request = httptest.NewRequest(http.MethodPost, path, nil) // the endpoint we're hitting
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // normally the router would populate these params from the path values,
+ // but because we're calling the function directly, we need to set them manually.
+ ctx.Params = gin.Params{
+ gin.Param{
+ Key: apiutil.IDKey,
+ Value: targetStatusID,
+ },
+ }
+
+ handler(ctx)
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ indented := bytes.Buffer{}
+ if err := json.Indent(&indented, b, "", " "); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ return recorder.Code, indented.String()
+}
+
+func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
+ var (
+ targetStatus = suite.testStatuses["local_account_1_status_1"]
+ path = fmt.Sprintf("http://localhost:8080/api%s", strings.ReplaceAll(statuses.MutePath, ":id", targetStatus.ID))
+ )
+
+ // Mute the status, ensure `muted` is `true`.
+ code, muted := suite.post(path, suite.statusModule.StatusMutePOSTHandler, targetStatus.ID)
+ suite.Equal(http.StatusOK, code)
+ suite.Equal(`{
+ "id": "01F8MHAMCHF6Y650WCRSCP4WMY",
+ "created_at": "2021-10-20T10:40:37.000Z",
+ "in_reply_to_id": null,
+ "in_reply_to_account_id": null,
+ "sensitive": true,
+ "spoiler_text": "introduction post",
+ "visibility": "public",
+ "language": "en",
+ "uri": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+ "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+ "replies_count": 2,
+ "reblogs_count": 1,
+ "favourites_count": 1,
+ "favourited": false,
+ "reblogged": false,
+ "muted": true,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "hello everyone!",
+ "reblog": null,
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "account": {
+ "id": "01F8MH1H7YV1Z7D2C8K2730QBF",
+ "username": "the_mighty_zork",
+ "acct": "the_mighty_zork",
+ "display_name": "original zork (he/they)",
+ "locked": false,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2022-05-20T11:09:18.000Z",
+ "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e",
+ "url": "http://localhost:8080/@the_mighty_zork",
+ "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "followers_count": 2,
+ "following_count": 2,
+ "statuses_count": 5,
+ "last_status_at": "2022-05-20T11:37:55.000Z",
+ "emojis": [],
+ "fields": [],
+ "enable_rss": true,
+ "role": {
+ "name": "user"
+ }
+ },
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null,
+ "text": "hello everyone!"
+}`, muted)
+
+ // Unmute the status, ensure `muted` is `false`.
+ code, unmuted := suite.post(path, suite.statusModule.StatusUnmutePOSTHandler, targetStatus.ID)
+ suite.Equal(http.StatusOK, code)
+ suite.Equal(`{
+ "id": "01F8MHAMCHF6Y650WCRSCP4WMY",
+ "created_at": "2021-10-20T10:40:37.000Z",
+ "in_reply_to_id": null,
+ "in_reply_to_account_id": null,
+ "sensitive": true,
+ "spoiler_text": "introduction post",
+ "visibility": "public",
+ "language": "en",
+ "uri": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+ "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
+ "replies_count": 2,
+ "reblogs_count": 1,
+ "favourites_count": 1,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "hello everyone!",
+ "reblog": null,
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "account": {
+ "id": "01F8MH1H7YV1Z7D2C8K2730QBF",
+ "username": "the_mighty_zork",
+ "acct": "the_mighty_zork",
+ "display_name": "original zork (he/they)",
+ "locked": false,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2022-05-20T11:09:18.000Z",
+ "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e",
+ "url": "http://localhost:8080/@the_mighty_zork",
+ "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "followers_count": 2,
+ "following_count": 2,
+ "statuses_count": 5,
+ "last_status_at": "2022-05-20T11:37:55.000Z",
+ "emojis": [],
+ "fields": [],
+ "enable_rss": true,
+ "role": {
+ "name": "user"
+ }
+ },
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null,
+ "text": "hello everyone!"
+}`, unmuted)
+}
+
+func TestStatusMuteTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusMuteTestSuite))
+}
diff --git a/internal/api/client/statuses/statusunmute.go b/internal/api/client/statuses/statusunmute.go
new file mode 100644
index 000000000..e657992ca
--- /dev/null
+++ b/internal/api/client/statuses/statusunmute.go
@@ -0,0 +1,99 @@
+// 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 .
+
+package statuses
+
+import (
+ "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"
+)
+
+// StatusUnmutePOSTHandler swagger:operation POST /api/v1/statuses/{id}/unmute statusUnmute
+//
+// Unmute a status's thread. This reenables notifications for future replies, likes, boosts etc in the thread of which the target status is a part.
+//
+// Target status must belong to you or mention you.
+//
+// Status thread mutes and unmutes are idempotent. If you already unmuted a thread, unmuting it again just means it stays unmuted and you'll get 200 OK back.
+//
+// ---
+// tags:
+// - statuses
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: id
+// type: string
+// description: Target status ID.
+// in: path
+// required: true
+//
+// security:
+// - OAuth2 Bearer:
+// - write:mutes
+//
+// responses:
+// '200':
+// name: status
+// description: The now-unmuted status.
+// schema:
+// "$ref": "#/definitions/status"
+// '400':
+// description: bad request; you're not part of the target status thread
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) StatusUnmutePOSTHandler(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
+ }
+
+ targetStatusID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiStatus, errWithCode := m.processor.Status().MuteRemove(c.Request.Context(), authed.Account, targetStatusID)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ c.JSON(http.StatusOK, apiStatus)
+}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index ec0ec3faa..777088f07 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -248,6 +248,7 @@ func (c *Caches) Sweep(threshold float64) {
c.GTS.Status().Trim(threshold)
c.GTS.StatusFave().Trim(threshold)
c.GTS.Tag().Trim(threshold)
+ c.GTS.ThreadMute().Trim(threshold)
c.GTS.Tombstone().Trim(threshold)
c.GTS.User().Trim(threshold)
c.Visibility.Trim(threshold)
diff --git a/internal/cache/gts.go b/internal/cache/gts.go
index 16a1585f7..d96b7267f 100644
--- a/internal/cache/gts.go
+++ b/internal/cache/gts.go
@@ -57,6 +57,7 @@ type GTSCaches struct {
statusFave *result.Cache[*gtsmodel.StatusFave]
statusFaveIDs *SliceCache[string]
tag *result.Cache[*gtsmodel.Tag]
+ threadMute *result.Cache[*gtsmodel.ThreadMute]
tombstone *result.Cache[*gtsmodel.Tombstone]
user *result.Cache[*gtsmodel.User]
@@ -93,6 +94,7 @@ func (c *GTSCaches) Init() {
c.initStatus()
c.initStatusFave()
c.initTag()
+ c.initThreadMute()
c.initStatusFaveIDs()
c.initTombstone()
c.initUser()
@@ -249,6 +251,11 @@ func (c *GTSCaches) Tag() *result.Cache[*gtsmodel.Tag] {
return c.tag
}
+// ThreadMute provides access to the gtsmodel ThreadMute database cache.
+func (c *GTSCaches) ThreadMute() *result.Cache[*gtsmodel.ThreadMute] {
+ return c.threadMute
+}
+
// StatusFaveIDs provides access to the status fave IDs list database cache.
func (c *GTSCaches) StatusFaveIDs() *SliceCache[string] {
return c.statusFaveIDs
@@ -712,6 +719,7 @@ func (c *GTSCaches) initStatus() {
{Name: "URI"},
{Name: "URL"},
{Name: "BoostOfID.AccountID"},
+ {Name: "ThreadID", Multi: true},
}, func(s1 *gtsmodel.Status) *gtsmodel.Status {
s2 := new(gtsmodel.Status)
*s2 = *s1
@@ -778,6 +786,28 @@ func (c *GTSCaches) initTag() {
c.tag.IgnoreErrors(ignoreErrors)
}
+func (c *GTSCaches) initThreadMute() {
+ cap := calculateResultCacheMax(
+ sizeOfThreadMute(), // model in-mem size.
+ config.GetCacheThreadMuteMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ c.threadMute = result.New([]result.Lookup{
+ {Name: "ID"},
+ {Name: "ThreadID", Multi: true},
+ {Name: "AccountID", Multi: true},
+ {Name: "ThreadID.AccountID"},
+ }, func(t1 *gtsmodel.ThreadMute) *gtsmodel.ThreadMute {
+ t2 := new(gtsmodel.ThreadMute)
+ *t2 = *t1
+ return t2
+ }, cap)
+
+ c.threadMute.IgnoreErrors(ignoreErrors)
+}
+
func (c *GTSCaches) initTombstone() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
diff --git a/internal/cache/size.go b/internal/cache/size.go
index f578b9402..3e77c68ac 100644
--- a/internal/cache/size.go
+++ b/internal/cache/size.go
@@ -194,6 +194,7 @@ func totalOfRatios() float64 {
config.GetCacheStatusFaveMemRatio() +
config.GetCacheStatusFaveIDsMemRatio() +
config.GetCacheTagMemRatio() +
+ config.GetCacheThreadMuteMemRatio() +
config.GetCacheTombstoneMemRatio() +
config.GetCacheUserMemRatio() +
config.GetCacheWebfingerMemRatio() +
@@ -511,6 +512,16 @@ func sizeofTag() uintptr {
}))
}
+func sizeOfThreadMute() uintptr {
+ return uintptr(size.Of(>smodel.ThreadMute{
+ ID: exampleID,
+ CreatedAt: exampleTime,
+ UpdatedAt: exampleTime,
+ ThreadID: exampleID,
+ AccountID: exampleID,
+ }))
+}
+
func sizeofTombstone() uintptr {
return uintptr(size.Of(>smodel.Tombstone{
ID: exampleID,
diff --git a/internal/config/config.go b/internal/config/config.go
index 314257831..a9fdef3c7 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -207,6 +207,7 @@ type CacheConfiguration struct {
StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"`
StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"`
TagMemRatio float64 `name:"tag-mem-ratio"`
+ ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"`
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
UserMemRatio float64 `name:"user-mem-ratio"`
WebfingerMemRatio float64 `name:"webfinger-mem-ratio"`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index fe2aa3acc..6ee52d162 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -174,6 +174,7 @@
StatusFaveMemRatio: 2,
StatusFaveIDsMemRatio: 3,
TagMemRatio: 2,
+ ThreadMuteMemRatio: 0.2,
TombstoneMemRatio: 0.5,
UserMemRatio: 0.25,
WebfingerMemRatio: 0.1,
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 46a239596..80687eb66 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -3174,6 +3174,31 @@ func GetCacheTagMemRatio() float64 { return global.GetCacheTagMemRatio() }
// SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field
func SetCacheTagMemRatio(v float64) { global.SetCacheTagMemRatio(v) }
+// GetCacheThreadMuteMemRatio safely fetches the Configuration value for state's 'Cache.ThreadMuteMemRatio' field
+func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.ThreadMuteMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheThreadMuteMemRatio safely sets the Configuration value for state's 'Cache.ThreadMuteMemRatio' field
+func (st *ConfigState) SetCacheThreadMuteMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.ThreadMuteMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheThreadMuteMemRatioFlag returns the flag name for the 'Cache.ThreadMuteMemRatio' field
+func CacheThreadMuteMemRatioFlag() string { return "cache-thread-mute-mem-ratio" }
+
+// GetCacheThreadMuteMemRatio safely fetches the value for global configuration 'Cache.ThreadMuteMemRatio' field
+func GetCacheThreadMuteMemRatio() float64 { return global.GetCacheThreadMuteMemRatio() }
+
+// SetCacheThreadMuteMemRatio safely sets the value for global configuration 'Cache.ThreadMuteMemRatio' field
+func SetCacheThreadMuteMemRatio(v float64) { global.SetCacheThreadMuteMemRatio(v) }
+
// GetCacheTombstoneMemRatio safely fetches the Configuration value for state's 'Cache.TombstoneMemRatio' field
func (st *ConfigState) GetCacheTombstoneMemRatio() (v float64) {
st.mutex.RLock()
diff --git a/internal/db/bundb/basic.go b/internal/db/bundb/basic.go
index eee2a12ef..e68903efa 100644
--- a/internal/db/bundb/basic.go
+++ b/internal/db/bundb/basic.go
@@ -135,7 +135,7 @@ func (b *basicDB) CreateAllTables(ctx context.Context) error {
>smodel.StatusToEmoji{},
>smodel.StatusFave{},
>smodel.StatusBookmark{},
- >smodel.StatusMute{},
+ >smodel.ThreadMute{},
>smodel.Tag{},
>smodel.User{},
>smodel.Emoji{},
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index 819fba810..393f32eec 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -54,6 +54,7 @@
>smodel.AccountToEmoji{},
>smodel.StatusToEmoji{},
>smodel.StatusToTag{},
+ >smodel.ThreadToStatus{},
}
// DBService satisfies the DB interface
@@ -79,6 +80,7 @@ type DBService struct {
db.StatusBookmark
db.StatusFave
db.Tag
+ db.Thread
db.Timeline
db.User
db.Tombstone
@@ -236,6 +238,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
conn: db,
state: state,
},
+ Thread: &threadDB{
+ db: db,
+ state: state,
+ },
Timeline: &timelineDB{
db: db,
state: state,
diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go
index 2ab539147..8245937b9 100644
--- a/internal/db/bundb/bundb_test.go
+++ b/internal/db/bundb/bundb_test.go
@@ -53,6 +53,7 @@ type BunDBStandardTestSuite struct {
testAccountNotes map[string]*gtsmodel.AccountNote
testMarkers map[string]*gtsmodel.Marker
testRules map[string]*gtsmodel.Rule
+ testThreads map[string]*gtsmodel.Thread
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@@ -75,6 +76,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testAccountNotes = testrig.NewTestAccountNotes()
suite.testMarkers = testrig.NewTestMarkers()
suite.testRules = testrig.NewTestRules()
+ suite.testThreads = testrig.NewTestThreads()
}
func (suite *BunDBStandardTestSuite) SetupTest() {
diff --git a/internal/db/bundb/migrations/20231016113235_mute_status_thread.go b/internal/db/bundb/migrations/20231016113235_mute_status_thread.go
new file mode 100644
index 000000000..c4a4a4fe7
--- /dev/null
+++ b/internal/db/bundb/migrations/20231016113235_mute_status_thread.go
@@ -0,0 +1,148 @@
+// 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 .
+
+package migrations
+
+import (
+ "context"
+ "strings"
+
+ gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/uptrace/bun"
+ "github.com/uptrace/bun/dialect"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ // Create thread table.
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.Thread{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Create thread intermediate table.
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.ThreadToStatus{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Drop old pkey constraint from
+ // deprecated status mute table.
+ //
+ // This is only necessary with postgres.
+ if tx.Dialect().Name() == dialect.PG {
+ if _, err := tx.ExecContext(
+ ctx,
+ "ALTER TABLE ? DROP CONSTRAINT IF EXISTS ?",
+ bun.Ident("status_mutes"),
+ bun.Safe("status_mutes_pkey"),
+ ); err != nil {
+ return err
+ }
+ }
+
+ // Drop old index.
+ if _, err := tx.
+ NewDropIndex().
+ Index("status_mutes_account_id_target_account_id_status_id_idx").
+ IfExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Drop deprecated status mute table.
+ if _, err := tx.
+ NewDropTable().
+ Table("status_mutes").
+ IfExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ // Create new thread mute table.
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.ThreadMute{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ log.Info(ctx, "creating a new index on the statuses table, please wait and don't interrupt it (this may take a few minutes)")
+
+ // Update statuses to add thread ID column.
+ _, err := tx.ExecContext(
+ ctx,
+ "ALTER TABLE ? ADD COLUMN ? CHAR(26)",
+ bun.Ident("statuses"),
+ bun.Ident("thread_id"),
+ )
+ if err != nil && !(strings.Contains(err.Error(), "already exists") ||
+ strings.Contains(err.Error(), "duplicate column name") ||
+ strings.Contains(err.Error(), "SQLSTATE 42701")) {
+ return err
+ }
+
+ // Index new + existing tables properly.
+ for table, indexes := range map[string]map[string][]string{
+ "threads": {
+ "threads_id_idx": {"id"},
+ },
+ "thread_mutes": {
+ "thread_mutes_id_idx": {"id"},
+ // Eg., check if target thread is muted by account.
+ "thread_mutes_thread_id_account_id_idx": {"thread_id", "account_id"},
+ },
+ "statuses": {
+ // Eg., select all statuses in a thread.
+ "statuses_thread_id_idx": {"thread_id"},
+ },
+ } {
+ for index, columns := range indexes {
+ if _, err := tx.
+ NewCreateIndex().
+ Table(table).
+ Index(index).
+ Column(columns...).
+ 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/status.go b/internal/db/bundb/status.go
index 26f0c1f38..0bd4ba1a9 100644
--- a/internal/db/bundb/status.go
+++ b/internal/db/bundb/status.go
@@ -324,6 +324,23 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
}
}
+ // If the status is threaded, create
+ // link between thread and status.
+ if status.ThreadID != "" {
+ if _, err := tx.
+ NewInsert().
+ Model(>smodel.ThreadToStatus{
+ ThreadID: status.ThreadID,
+ StatusID: status.ID,
+ }).
+ On("CONFLICT (?, ?) DO NOTHING", bun.Ident("thread_id"), bun.Ident("status_id")).
+ Exec(ctx); err != nil {
+ if !errors.Is(err, db.ErrAlreadyExists) {
+ return err
+ }
+ }
+ }
+
// Finally, insert the status
_, err := tx.NewInsert().Model(status).Exec(ctx)
return err
@@ -390,6 +407,23 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
}
}
+ // If the status is threaded, create
+ // link between thread and status.
+ if status.ThreadID != "" {
+ if _, err := tx.
+ NewInsert().
+ Model(>smodel.ThreadToStatus{
+ ThreadID: status.ThreadID,
+ StatusID: status.ID,
+ }).
+ On("CONFLICT (?, ?) DO NOTHING", bun.Ident("thread_id"), bun.Ident("status_id")).
+ Exec(ctx); err != nil {
+ if !errors.Is(err, db.ErrAlreadyExists) {
+ return err
+ }
+ }
+ }
+
// Finally, update the status
_, err := tx.
NewUpdate().
@@ -439,6 +473,17 @@ func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) error {
return err
}
+ // Delete links between this status
+ // and any threads it was a part of.
+ _, err = tx.
+ NewDelete().
+ TableExpr("? AS ?", bun.Ident("thread_to_statuses"), bun.Ident("thread_to_status")).
+ Where("? = ?", bun.Ident("thread_to_status.status_id"), id).
+ Exec(ctx)
+ if err != nil {
+ return err
+ }
+
// delete the status itself
if _, err := tx.
NewDelete().
@@ -634,16 +679,6 @@ func (s *statusDB) getStatusBoostIDs(ctx context.Context, statusID string) ([]st
})
}
-func (s *statusDB) IsStatusMutedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) {
- q := s.db.
- NewSelect().
- TableExpr("? AS ?", bun.Ident("status_mutes"), bun.Ident("status_mute")).
- Where("? = ?", bun.Ident("status_mute.status_id"), status.ID).
- Where("? = ?", bun.Ident("status_mute.account_id"), accountID)
-
- return s.db.Exists(ctx, q)
-}
-
func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) {
q := s.db.
NewSelect().
diff --git a/internal/db/bundb/thread.go b/internal/db/bundb/thread.go
new file mode 100644
index 000000000..e6d6154d4
--- /dev/null
+++ b/internal/db/bundb/thread.go
@@ -0,0 +1,117 @@
+// 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 .
+
+package bundb
+
+import (
+ "context"
+ "errors"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/uptrace/bun"
+)
+
+type threadDB struct {
+ db *DB
+ state *state.State
+}
+
+func (t *threadDB) PutThread(ctx context.Context, thread *gtsmodel.Thread) error {
+ _, err := t.db.
+ NewInsert().
+ Model(thread).
+ Exec(ctx)
+
+ return err
+}
+
+func (t *threadDB) GetThreadMute(ctx context.Context, id string) (*gtsmodel.ThreadMute, error) {
+ return t.state.Caches.GTS.ThreadMute().Load("ID", func() (*gtsmodel.ThreadMute, error) {
+ var threadMute gtsmodel.ThreadMute
+
+ q := t.db.
+ NewSelect().
+ Model(&threadMute).
+ Where("? = ?", bun.Ident("thread_mute.id"), id)
+
+ if err := q.Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return &threadMute, nil
+ }, id)
+}
+
+func (t *threadDB) GetThreadMutedByAccount(
+ ctx context.Context,
+ threadID string,
+ accountID string,
+) (*gtsmodel.ThreadMute, error) {
+ return t.state.Caches.GTS.ThreadMute().Load("ThreadID.AccountID", func() (*gtsmodel.ThreadMute, error) {
+ var threadMute gtsmodel.ThreadMute
+
+ q := t.db.
+ NewSelect().
+ Model(&threadMute).
+ Where("? = ?", bun.Ident("thread_mute.thread_id"), threadID).
+ Where("? = ?", bun.Ident("thread_mute.account_id"), accountID)
+
+ if err := q.Scan(ctx); err != nil {
+ return nil, err
+ }
+
+ return &threadMute, nil
+ }, threadID, accountID)
+}
+
+func (t *threadDB) IsThreadMutedByAccount(
+ ctx context.Context,
+ threadID string,
+ accountID string,
+) (bool, error) {
+ if threadID == "" {
+ return false, nil
+ }
+
+ mute, err := t.GetThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return false, err
+ }
+
+ return (mute != nil), nil
+}
+
+func (t *threadDB) PutThreadMute(ctx context.Context, threadMute *gtsmodel.ThreadMute) error {
+ return t.state.Caches.GTS.ThreadMute().Store(threadMute, func() error {
+ _, err := t.db.NewInsert().Model(threadMute).Exec(ctx)
+ return err
+ })
+}
+
+func (t *threadDB) DeleteThreadMute(ctx context.Context, id string) error {
+ if _, err := t.db.
+ NewDelete().
+ TableExpr("? AS ?", bun.Ident("thread_mutes"), bun.Ident("thread_mute")).
+ Where("? = ?", bun.Ident("thread_mute.id"), id).Exec(ctx); err != nil {
+ return err
+ }
+
+ t.state.Caches.GTS.ThreadMute().Invalidate("ID", id)
+ return nil
+}
diff --git a/internal/db/bundb/thread_test.go b/internal/db/bundb/thread_test.go
new file mode 100644
index 000000000..4d14f73e2
--- /dev/null
+++ b/internal/db/bundb/thread_test.go
@@ -0,0 +1,91 @@
+// 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 .
+
+package bundb_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+type ThreadTestSuite struct {
+ BunDBStandardTestSuite
+}
+
+func (suite *ThreadTestSuite) TestPutThread() {
+ suite.NoError(
+ suite.db.PutThread(
+ context.Background(),
+ >smodel.Thread{
+ ID: "01HCWK4HVQ4VGSS1G4VQP3AXZF",
+ },
+ ),
+ )
+}
+
+func (suite *ThreadTestSuite) TestMuteUnmuteThread() {
+ var (
+ threadID = suite.testThreads["local_account_1_status_1"].ID
+ accountID = suite.testAccounts["local_account_1"].ID
+ ctx = context.Background()
+ threadMute = >smodel.ThreadMute{
+ ID: "01HD3K14B62YJHH4RR0DSZ1EQ2",
+ ThreadID: threadID,
+ AccountID: accountID,
+ }
+ )
+
+ // Mute the thread and ensure it's actually muted.
+ if err := suite.db.PutThreadMute(ctx, threadMute); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ muted, err := suite.db.IsThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ if !muted {
+ suite.FailNow("", "expected thread %s to be muted by account %s", threadID, accountID)
+ }
+
+ _, err = suite.db.GetThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Unmute the thread and ensure it's actually unmuted.
+ if err := suite.db.DeleteThreadMute(ctx, threadMute.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ muted, err = suite.db.IsThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ if muted {
+ suite.FailNow("", "expected thread %s to not be muted by account %s", threadID, accountID)
+ }
+}
+
+func TestThreadTestSuite(t *testing.T) {
+ suite.Run(t, new(ThreadTestSuite))
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index 056d03e23..41b253834 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -45,6 +45,7 @@ type DB interface {
StatusBookmark
StatusFave
Tag
+ Thread
Timeline
User
Tombstone
diff --git a/internal/db/status.go b/internal/db/status.go
index f4421fa2e..0be37421a 100644
--- a/internal/db/status.go
+++ b/internal/db/status.go
@@ -80,9 +80,6 @@ type Status interface {
// If onlyDirect is true, only the immediate children will be returned.
GetStatusChildren(ctx context.Context, status *gtsmodel.Status, onlyDirect bool, minID string) ([]*gtsmodel.Status, error)
- // IsStatusMutedBy checks if a given status has been muted by a given account ID
- IsStatusMutedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error)
-
// IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID
IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error)
}
diff --git a/internal/db/thread.go b/internal/db/thread.go
new file mode 100644
index 000000000..dd494167a
--- /dev/null
+++ b/internal/db/thread.go
@@ -0,0 +1,48 @@
+// 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 .
+
+package db
+
+import (
+ "context"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+)
+
+// Thread contains functions for getting/creating
+// status threads and thread mutes in the database.
+type Thread interface {
+ // PutThread inserts a new thread.
+ PutThread(ctx context.Context, thread *gtsmodel.Thread) error
+
+ // GetThreadMute gets a single threadMute by its ID.
+ GetThreadMute(ctx context.Context, id string) (*gtsmodel.ThreadMute, error)
+
+ // GetThreadMutedByAccount gets a threadMute targeting the
+ // given thread, created by the given accountID, if it exists.
+ GetThreadMutedByAccount(ctx context.Context, threadID string, accountID string) (*gtsmodel.ThreadMute, error)
+
+ // IsThreadMutedByAccount returns true if threadID is muted
+ // by given account. Empty thread ID will return false early.
+ IsThreadMutedByAccount(ctx context.Context, threadID string, accountID string) (bool, error)
+
+ // PutThreadMute inserts a new threadMute.
+ PutThreadMute(ctx context.Context, threadMute *gtsmodel.ThreadMute) error
+
+ // DeleteThreadMute deletes threadMute with the given ID.
+ DeleteThreadMute(ctx context.Context, id string) error
+}
diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go
index bb6a8002c..89b3088c8 100644
--- a/internal/federation/dereferencing/status.go
+++ b/internal/federation/dereferencing/status.go
@@ -24,6 +24,8 @@
"net/url"
"time"
+ "slices"
+
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -293,6 +295,12 @@ func (d *Dereferencer) enrichStatus(
return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err)
}
+ // Now that we know who this status replies to (handled by ASStatusToStatus)
+ // and who it mentions, we can add a ThreadID to it if necessary.
+ if err := d.threadStatus(ctx, latestStatus); err != nil {
+ return nil, nil, gtserror.Newf("error checking / creating threadID for status %s: %w", uri, err)
+ }
+
// Ensure the status' tags are populated, (changes are expected / okay).
if err := d.fetchStatusTags(ctx, latestStatus); err != nil {
return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err)
@@ -410,6 +418,57 @@ func (d *Dereferencer) fetchStatusMentions(ctx context.Context, requestUser stri
return nil
}
+func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status) error {
+ if status.InReplyTo != nil {
+ if parentThreadID := status.InReplyTo.ThreadID; parentThreadID != "" {
+ // Simplest case: parent status
+ // is threaded, so inherit threadID.
+ status.ThreadID = parentThreadID
+ return nil
+ }
+ }
+
+ // Parent wasn't threaded. If this
+ // status mentions a local account,
+ // we should thread it so that local
+ // account can mute it if they want.
+ mentionsLocal := slices.ContainsFunc(
+ status.Mentions,
+ func(m *gtsmodel.Mention) bool {
+ // If TargetAccount couldn't
+ // be deref'd, we know it's not
+ // a local account, so only
+ // check for non-nil accounts.
+ return m.TargetAccount != nil &&
+ m.TargetAccount.IsLocal()
+ },
+ )
+
+ if !mentionsLocal {
+ // Status doesn't mention a
+ // local account, so we don't
+ // need to thread it.
+ return nil
+ }
+
+ // Status mentions a local account.
+ // Create a new thread and assign
+ // it to the status.
+ threadID := id.NewULID()
+
+ if err := d.state.DB.PutThread(
+ ctx,
+ >smodel.Thread{
+ ID: threadID,
+ },
+ ); err != nil {
+ return gtserror.Newf("error inserting new thread in db: %w", err)
+ }
+
+ status.ThreadID = threadID
+ return nil
+}
+
func (d *Dereferencer) fetchStatusTags(ctx context.Context, status *gtsmodel.Status) error {
// Allocate new slice to take the yet-to-be determined tag IDs.
status.TagIDs = make([]string, len(status.Tags))
diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go
index 3e8880798..fe8aa4a7b 100644
--- a/internal/gtsmodel/status.go
+++ b/internal/gtsmodel/status.go
@@ -20,6 +20,8 @@
import (
"time"
+ "slices"
+
"github.com/superseriousbusiness/gotosocial/internal/log"
)
@@ -54,6 +56,7 @@ type Status struct {
BoostOfAccountID string `bun:"type:CHAR(26),nullzero"` // id of the account that owns the boosted status
BoostOf *Status `bun:"-"` // status that corresponds to boostOfID
BoostOfAccount *Account `bun:"rel:belongs-to"` // account that corresponds to boostOfAccountID
+ ThreadID string `bun:"type:CHAR(26),nullzero"` // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null
ContentWarning string `bun:",nullzero"` // cw string for this status
Visibility Visibility `bun:",nullzero,notnull"` // visibility entry for this status
Sensitive *bool `bun:",nullzero,notnull,default:false"` // mark the status as sensitive?
@@ -241,13 +244,15 @@ func (s *Status) EmojisUpToDate(other *Status) bool {
}
// MentionsAccount returns whether status mentions the given account ID.
-func (s *Status) MentionsAccount(id string) bool {
- for _, mention := range s.Mentions {
- if mention.TargetAccountID == id {
- return true
- }
- }
- return false
+func (s *Status) MentionsAccount(accountID string) bool {
+ return slices.ContainsFunc(s.Mentions, func(m *Mention) bool {
+ return m.TargetAccountID == accountID
+ })
+}
+
+// BelongsToAccount returns whether status belongs to the given account ID.
+func (s *Status) BelongsToAccount(accountID string) bool {
+ return s.AccountID == accountID
}
// StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags.
diff --git a/internal/gtsmodel/statusmute.go b/internal/gtsmodel/statusmute.go
index b8aca1c7a..a6f895a29 100644
--- a/internal/gtsmodel/statusmute.go
+++ b/internal/gtsmodel/statusmute.go
@@ -19,7 +19,7 @@
import "time"
-// StatusMute refers to one account having muted the status of another account or its own.
+// StatusMute IS DEPRECATED -- USE THREADMUTE INSTEAD NOW! THIS TABLE DOESN'T EXIST ANYMORE!
type StatusMute 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
diff --git a/internal/gtsmodel/thread.go b/internal/gtsmodel/thread.go
new file mode 100644
index 000000000..5d5af1993
--- /dev/null
+++ b/internal/gtsmodel/thread.go
@@ -0,0 +1,32 @@
+// 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 .
+
+package gtsmodel
+
+// Thread represents one thread of statuses.
+// TODO: add more fields here if necessary.
+type Thread struct {
+ ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
+ StatusIDs []string `bun:"-"` // ids of statuses belonging to this thread (order not guaranteed)
+}
+
+// ThreadToStatus is an intermediate struct to facilitate the
+// many2many relationship between a thread and one or more statuses.
+type ThreadToStatus struct {
+ ThreadID string `bun:"type:CHAR(26),unique:statusthread,nullzero,notnull"`
+ StatusID string `bun:"type:CHAR(26),unique:statusthread,nullzero,notnull"`
+}
diff --git a/internal/gtsmodel/threadmute.go b/internal/gtsmodel/threadmute.go
new file mode 100644
index 000000000..170f568a1
--- /dev/null
+++ b/internal/gtsmodel/threadmute.go
@@ -0,0 +1,29 @@
+// 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 .
+
+package gtsmodel
+
+import "time"
+
+// ThreadMute represents an account-level mute of a thread of statuses.
+type ThreadMute 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
+ ThreadID string `bun:"type:CHAR(26),nullzero,notnull,unique:thread_mute_thread_id_account_id"` // ID of the muted thread
+ AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique:thread_mute_thread_id_account_id"` // Account ID of the creator of this mute
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index a24683e69..47f14a686 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -164,7 +164,7 @@ func NewProcessor(
processor.report = report.New(state, converter)
processor.timeline = timeline.New(state, converter, filter)
processor.search = search.New(state, federator, converter, filter)
- processor.status = status.New(state, federator, converter, filter, parseMentionFunc)
+ processor.status = status.New(&commonProcessor, state, federator, converter, filter, parseMentionFunc)
processor.stream = streamProcessor
processor.user = user.New(state, emailSender)
diff --git a/internal/processing/status/bookmark.go b/internal/processing/status/bookmark.go
index 64e3fc1fd..634529ba4 100644
--- a/internal/processing/status/bookmark.go
+++ b/internal/processing/status/bookmark.go
@@ -29,16 +29,31 @@
"github.com/superseriousbusiness/gotosocial/internal/id"
)
+func (p *Processor) getBookmarkableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, string, gtserror.WithCode) {
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
+ if errWithCode != nil {
+ return nil, "", errWithCode
+ }
+
+ bookmarkID, err := p.state.DB.GetStatusBookmarkID(ctx, requestingAccount.ID, targetStatus.ID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = fmt.Errorf("getBookmarkTarget: error checking existing bookmark: %w", err)
+ return nil, "", gtserror.NewErrorInternalError(err)
+ }
+
+ return targetStatus, bookmarkID, nil
+}
+
// BookmarkCreate adds a bookmark for the requestingAccount, targeting the given status (no-op if bookmark already exists).
func (p *Processor) BookmarkCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, existingBookmarkID, errWithCode := p.getBookmarkTarget(ctx, requestingAccount, targetStatusID)
+ targetStatus, existingBookmarkID, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
if existingBookmarkID != "" {
// Status is already bookmarked.
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// Create and store a new bookmark.
@@ -57,24 +72,24 @@ func (p *Processor) BookmarkCreate(ctx context.Context, requestingAccount *gtsmo
return nil, gtserror.NewErrorInternalError(err)
}
- if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
+ if err := p.c.InvalidateTimelinedStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
err = gtserror.Newf("error invalidating status from timelines: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// BookmarkRemove removes a bookmark for the requesting account, targeting the given status (no-op if bookmark doesn't exist).
func (p *Processor) BookmarkRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, existingBookmarkID, errWithCode := p.getBookmarkTarget(ctx, requestingAccount, targetStatusID)
+ targetStatus, existingBookmarkID, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
if existingBookmarkID == "" {
// Status isn't bookmarked.
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// We have a bookmark to remove.
@@ -83,25 +98,10 @@ func (p *Processor) BookmarkRemove(ctx context.Context, requestingAccount *gtsmo
return nil, gtserror.NewErrorInternalError(err)
}
- if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
+ if err := p.c.InvalidateTimelinedStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
err = gtserror.Newf("error invalidating status from timelines: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
-}
-
-func (p *Processor) getBookmarkTarget(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, string, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
- if errWithCode != nil {
- return nil, "", errWithCode
- }
-
- bookmarkID, err := p.state.DB.GetStatusBookmarkID(ctx, requestingAccount.ID, targetStatus.ID)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = fmt.Errorf("getBookmarkTarget: error checking existing bookmark: %w", err)
- return nil, "", gtserror.NewErrorInternalError(err)
- }
-
- return targetStatus, bookmarkID, nil
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go
index d4bdc3f43..76a0a75bc 100644
--- a/internal/processing/status/boost.go
+++ b/internal/processing/status/boost.go
@@ -85,7 +85,7 @@ func (p *Processor) BoostCreate(ctx context.Context, requestingAccount *gtsmodel
TargetAccount: targetStatus.Account,
})
- return p.apiStatus(ctx, boostWrapperStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, boostWrapperStatus)
}
// BoostRemove processes the unboost/unreblog of a given status, returning the status if all is well.
@@ -129,7 +129,7 @@ func (p *Processor) BoostRemove(ctx context.Context, requestingAccount *gtsmodel
})
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings.
diff --git a/internal/processing/status/common.go b/internal/processing/status/common.go
deleted file mode 100644
index 71eef70a1..000000000
--- a/internal/processing/status/common.go
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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 .
-
-package status
-
-import (
- "context"
- "fmt"
-
- "codeberg.org/gruf/go-kv"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- "github.com/superseriousbusiness/gotosocial/internal/gtserror"
- "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- "github.com/superseriousbusiness/gotosocial/internal/log"
-)
-
-func (p *Processor) apiStatus(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, gtserror.WithCode) {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, targetStatus, requestingAccount)
- if err != nil {
- err = gtserror.Newf("error converting status %s to frontend representation: %w", targetStatus.ID, err)
- return nil, gtserror.NewErrorInternalError(err)
- }
-
- return apiStatus, nil
-}
-
-func (p *Processor) getVisibleStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, gtserror.WithCode) {
- targetStatus, err := p.state.DB.GetStatusByID(ctx, targetStatusID)
- if err != nil {
- err = fmt.Errorf("getVisibleStatus: db error fetching status %s: %w", targetStatusID, err)
- return nil, gtserror.NewErrorNotFound(err)
- }
-
- if requestingAccount != nil {
- // Ensure the status is up-to-date.
- p.federator.RefreshStatusAsync(ctx,
- requestingAccount.Username,
- targetStatus,
- nil,
- false,
- )
- }
-
- visible, err := p.filter.StatusVisible(ctx, requestingAccount, targetStatus)
- if err != nil {
- err = fmt.Errorf("getVisibleStatus: error seeing if status %s is visible: %w", targetStatus.ID, err)
- return nil, gtserror.NewErrorNotFound(err)
- }
-
- if !visible {
- err = fmt.Errorf("getVisibleStatus: status %s is not visible to requesting account", targetStatusID)
- return nil, gtserror.NewErrorNotFound(err)
- }
-
- return targetStatus, nil
-}
-
-// invalidateStatus is a shortcut function for invalidating the prepared/cached
-// representation one status in the home timeline and all list timelines of the
-// given accountID. It should only be called in cases where a status update
-// does *not* need to be passed into the processor via the worker queue, since
-// such invalidation will, in that case, be handled by the processor instead.
-func (p *Processor) invalidateStatus(ctx context.Context, accountID string, statusID string) error {
- // Get lists first + bail if this fails.
- lists, err := p.state.DB.GetListsForAccountID(ctx, accountID)
- if err != nil {
- return gtserror.Newf("db error getting lists for account %s: %w", accountID, err)
- }
-
- l := log.WithContext(ctx).WithFields(kv.Fields{
- {"accountID", accountID},
- {"statusID", statusID},
- }...)
-
- // Unprepare item from home + list timelines, just log
- // if something goes wrong since this is not a showstopper.
-
- if err := p.state.Timelines.Home.UnprepareItem(ctx, accountID, statusID); err != nil {
- l.Errorf("error unpreparing item from home timeline: %v", err)
- }
-
- for _, list := range lists {
- if err := p.state.Timelines.List.UnprepareItem(ctx, list.ID, statusID); err != nil {
- l.Errorf("error unpreparing item from list timeline %s: %v", list.ID, err)
- }
- }
-
- return nil
-}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index ee4466b1b..40b3f2df2 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -70,6 +70,10 @@ func (p *Processor) Create(ctx context.Context, requestingAccount *gtsmodel.Acco
return nil, errWithCode
}
+ if errWithCode := p.processThreadID(ctx, status); errWithCode != nil {
+ return nil, errWithCode
+ }
+
if errWithCode := p.processMediaIDs(ctx, form, requestingAccount.ID, status); errWithCode != nil {
return nil, errWithCode
}
@@ -99,7 +103,7 @@ func (p *Processor) Create(ctx context.Context, requestingAccount *gtsmodel.Acco
OriginAccount: requestingAccount,
})
- return p.apiStatus(ctx, status, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, status)
}
func (p *Processor) processReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode {
@@ -141,12 +145,43 @@ func (p *Processor) processReplyToID(ctx context.Context, form *apimodel.Advance
// Set status fields from inReplyTo.
status.InReplyToID = inReplyTo.ID
+ status.InReplyTo = inReplyTo
status.InReplyToURI = inReplyTo.URI
status.InReplyToAccountID = inReplyTo.AccountID
return nil
}
+func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status) gtserror.WithCode {
+ // Status takes the thread ID
+ // of whatever it replies to.
+ if status.InReplyTo != nil {
+ status.ThreadID = status.InReplyTo.ThreadID
+ return nil
+ }
+
+ // Status doesn't reply to anything,
+ // so it's a new local top-level status
+ // and therefore needs a thread ID.
+ threadID := id.NewULID()
+
+ if err := p.state.DB.PutThread(
+ ctx,
+ >smodel.Thread{
+ ID: threadID,
+ },
+ ); err != nil {
+ err := gtserror.Newf("error inserting new thread in db: %w", err)
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ // Future replies to this status
+ // (if any) will inherit this thread ID.
+ status.ThreadID = threadID
+
+ return nil
+}
+
func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode {
if form.MediaIDs == nil {
return nil
diff --git a/internal/processing/status/delete.go b/internal/processing/status/delete.go
index 5549e0329..261086bdb 100644
--- a/internal/processing/status/delete.go
+++ b/internal/processing/status/delete.go
@@ -45,7 +45,7 @@ func (p *Processor) Delete(ctx context.Context, requestingAccount *gtsmodel.Acco
}
// Parse the status to API model BEFORE deleting it.
- apiStatus, errWithCode := p.apiStatus(ctx, targetStatus, requestingAccount)
+ apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
if errWithCode != nil {
return nil, errWithCode
}
diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go
index e2bf03594..a16fb6620 100644
--- a/internal/processing/status/fave.go
+++ b/internal/processing/status/fave.go
@@ -33,16 +33,36 @@
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
+func (p *Processor) getFaveableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, *gtsmodel.StatusFave, gtserror.WithCode) {
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
+ if errWithCode != nil {
+ return nil, nil, errWithCode
+ }
+
+ if !*targetStatus.Likeable {
+ err := errors.New("status is not faveable")
+ return nil, nil, gtserror.NewErrorForbidden(err, err.Error())
+ }
+
+ fave, err := p.state.DB.GetStatusFave(ctx, requestingAccount.ID, targetStatusID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err = fmt.Errorf("getFaveTarget: error checking existing fave: %w", err)
+ return nil, nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return targetStatus, fave, nil
+}
+
// FaveCreate adds a fave for the requestingAccount, targeting the given status (no-op if fave already exists).
func (p *Processor) FaveCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, existingFave, errWithCode := p.getFaveTarget(ctx, requestingAccount, targetStatusID)
+ targetStatus, existingFave, errWithCode := p.getFaveableStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
if existingFave != nil {
// Status is already faveed.
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// Create and store a new fave
@@ -72,19 +92,19 @@ func (p *Processor) FaveCreate(ctx context.Context, requestingAccount *gtsmodel.
TargetAccount: targetStatus.Account,
})
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// FaveRemove removes a fave for the requesting account, targeting the given status (no-op if fave doesn't exist).
func (p *Processor) FaveRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, existingFave, errWithCode := p.getFaveTarget(ctx, requestingAccount, targetStatusID)
+ targetStatus, existingFave, errWithCode := p.getFaveableStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
if existingFave == nil {
// Status isn't faveed.
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// We have a fave to remove.
@@ -102,12 +122,12 @@ func (p *Processor) FaveRemove(ctx context.Context, requestingAccount *gtsmodel.
TargetAccount: targetStatus.Account,
})
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.
func (p *Processor) FavedBy(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
@@ -145,23 +165,3 @@ func (p *Processor) FavedBy(ctx context.Context, requestingAccount *gtsmodel.Acc
return apiAccounts, nil
}
-
-func (p *Processor) getFaveTarget(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, *gtsmodel.StatusFave, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
- if errWithCode != nil {
- return nil, nil, errWithCode
- }
-
- if !*targetStatus.Likeable {
- err := errors.New("status is not faveable")
- return nil, nil, gtserror.NewErrorForbidden(err, err.Error())
- }
-
- fave, err := p.state.DB.GetStatusFave(ctx, requestingAccount.ID, targetStatusID)
- if err != nil && !errors.Is(err, db.ErrNoEntries) {
- err = fmt.Errorf("getFaveTarget: error checking existing fave: %w", err)
- return nil, nil, gtserror.NewErrorInternalError(err)
- }
-
- return targetStatus, fave, nil
-}
diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go
index cf79b96a0..8c939a61e 100644
--- a/internal/processing/status/get.go
+++ b/internal/processing/status/get.go
@@ -28,17 +28,17 @@
// Get gets the given status, taking account of privacy settings and blocks etc.
func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// ContextGet returns the context (previous and following posts) from the given status ID.
func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
diff --git a/internal/processing/status/mute.go b/internal/processing/status/mute.go
new file mode 100644
index 000000000..1663ee0bc
--- /dev/null
+++ b/internal/processing/status/mute.go
@@ -0,0 +1,146 @@
+// 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 .
+
+package status
+
+import (
+ "context"
+ "errors"
+
+ 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"
+)
+
+// getMuteableStatus fetches targetStatusID status and
+// ensures that requestingAccount can mute or unmute it.
+//
+// It checks:
+// - Status exists and is visible to requester.
+// - Status belongs to or mentions requesting account.
+// - Status is not a boost.
+// - Status has a thread ID.
+func (p *Processor) getMuteableStatus(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetStatusID string,
+) (*gtsmodel.Status, gtserror.WithCode) {
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ if !targetStatus.BelongsToAccount(requestingAccount.ID) &&
+ !targetStatus.MentionsAccount(requestingAccount.ID) {
+ err := gtserror.Newf("status %s does not belong to or mention account %s", targetStatusID, requestingAccount.ID)
+ return nil, gtserror.NewErrorNotFound(err)
+ }
+
+ if targetStatus.BoostOfID != "" {
+ err := gtserror.New("cannot mute or unmute boosts")
+ return nil, gtserror.NewErrorBadRequest(err, err.Error())
+ }
+
+ if targetStatus.ThreadID == "" {
+ err := gtserror.New("cannot mute or unmute status with no threadID")
+ return nil, gtserror.NewErrorBadRequest(err, err.Error())
+ }
+
+ return targetStatus, nil
+}
+
+func (p *Processor) MuteCreate(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetStatusID string,
+) (*apimodel.Status, gtserror.WithCode) {
+ targetStatus, errWithCode := p.getMuteableStatus(ctx, requestingAccount, targetStatusID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ var (
+ threadID = targetStatus.ThreadID
+ accountID = requestingAccount.ID
+ )
+
+ // Check if mute already exists for this thread ID.
+ threadMute, err := p.state.DB.GetThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Real db error.
+ err := gtserror.Newf("db error fetching mute of thread %s for account %s", threadID, accountID)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if threadMute != nil {
+ // Thread mute already exists.
+ // Our job here is done ("but you didn't do anything!").
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
+ }
+
+ // Gotta create a mute.
+ if err := p.state.DB.PutThreadMute(ctx, >smodel.ThreadMute{
+ ID: id.NewULID(),
+ ThreadID: threadID,
+ AccountID: accountID,
+ }); err != nil {
+ err := gtserror.Newf("db error putting mute of thread %s for account %s", threadID, accountID)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
+}
+
+func (p *Processor) MuteRemove(
+ ctx context.Context,
+ requestingAccount *gtsmodel.Account,
+ targetStatusID string,
+) (*apimodel.Status, gtserror.WithCode) {
+ targetStatus, errWithCode := p.getMuteableStatus(ctx, requestingAccount, targetStatusID)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ var (
+ threadID = targetStatus.ThreadID
+ accountID = requestingAccount.ID
+ )
+
+ // Check if mute exists for this thread ID.
+ threadMute, err := p.state.DB.GetThreadMutedByAccount(ctx, threadID, accountID)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Real db error.
+ err := gtserror.Newf("db error fetching mute of thread %s for account %s", threadID, accountID)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if threadMute == nil {
+ // Thread mute doesn't exist.
+ // Our job here is done ("but you didn't do anything!").
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
+ }
+
+ // Gotta remove the mute.
+ if err := p.state.DB.DeleteThreadMute(ctx, threadMute.ID); err != nil {
+ err := gtserror.Newf("db error deleting mute of thread %s for account %s", threadID, accountID)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
+}
diff --git a/internal/processing/status/pin.go b/internal/processing/status/pin.go
index c5981b699..b31288a64 100644
--- a/internal/processing/status/pin.go
+++ b/internal/processing/status/pin.go
@@ -39,7 +39,7 @@
// - Status is public, unlisted, or followers-only.
// - Status is not a boost.
func (p *Processor) getPinnableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, gtserror.WithCode) {
- targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID)
+ targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, requestingAccount, targetStatusID)
if errWithCode != nil {
return nil, errWithCode
}
@@ -99,12 +99,12 @@ func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.A
return nil, gtserror.NewErrorInternalError(err)
}
- if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
+ if err := p.c.InvalidateTimelinedStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
err = gtserror.Newf("error invalidating status from timelines: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
// PinRemove unpins the target status from the top of requestingAccount's profile, if possible.
@@ -125,7 +125,7 @@ func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.A
}
if targetStatus.PinnedAt.IsZero() {
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
targetStatus.PinnedAt = time.Time{}
@@ -134,10 +134,10 @@ func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.A
return nil, gtserror.NewErrorInternalError(err)
}
- if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
+ if err := p.c.InvalidateTimelinedStatus(ctx, requestingAccount.ID, targetStatusID); err != nil {
err = gtserror.Newf("error invalidating status from timelines: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
- return p.apiStatus(ctx, targetStatus, requestingAccount)
+ return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
}
diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go
index 28ea64542..b45b1651e 100644
--- a/internal/processing/status/status.go
+++ b/internal/processing/status/status.go
@@ -20,6 +20,7 @@
import (
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
@@ -27,6 +28,9 @@
)
type Processor struct {
+ // common processor logic
+ c *common.Processor
+
state *state.State
federator *federation.Federator
converter *typeutils.Converter
@@ -36,8 +40,16 @@ type Processor struct {
}
// New returns a new status processor.
-func New(state *state.State, federator *federation.Federator, converter *typeutils.Converter, filter *visibility.Filter, parseMention gtsmodel.ParseMentionFunc) Processor {
+func New(
+ common *common.Processor,
+ state *state.State,
+ federator *federation.Federator,
+ converter *typeutils.Converter,
+ filter *visibility.Filter,
+ parseMention gtsmodel.ParseMentionFunc,
+) Processor {
return Processor{
+ c: common,
state: state,
federator: federator,
converter: converter,
diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go
index 0507df484..22486ecf2 100644
--- a/internal/processing/status/status_test.go
+++ b/internal/processing/status/status_test.go
@@ -24,6 +24,7 @@
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
@@ -94,7 +95,9 @@ func (suite *StatusStandardTestSuite) SetupTest() {
suite.typeConverter,
)
- suite.status = status.New(&suite.state, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(suite.db, suite.federator))
+ common := common.New(&suite.state, suite.typeConverter, suite.federator, filter)
+
+ suite.status = status.New(&common, &suite.state, suite.federator, suite.typeConverter, filter, processing.GetParseMentionFunc(suite.db, suite.federator))
testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../../testrig/media")
diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go
index ff316b1f4..789145226 100644
--- a/internal/processing/workers/fromclientapi.go
+++ b/internal/processing/workers/fromclientapi.go
@@ -260,6 +260,11 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg messages.FromClientAPI)
return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", cMsg.GTSModel)
}
+ // Ensure fave populated.
+ if err := p.state.DB.PopulateStatusFave(ctx, fave); err != nil {
+ return gtserror.Newf("error populating status fave: %w", err)
+ }
+
if err := p.surface.notifyFave(ctx, fave); err != nil {
return gtserror.Newf("error notifying fave: %w", err)
}
diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go
index e5a098c31..05526f437 100644
--- a/internal/processing/workers/fromclientapi_test.go
+++ b/internal/processing/workers/fromclientapi_test.go
@@ -75,6 +75,7 @@ func (suite *FromClientAPITestSuite) newStatus(
newStatus.InReplyToAccountID = replyToStatus.AccountID
newStatus.InReplyToID = replyToStatus.ID
newStatus.InReplyToURI = replyToStatus.URI
+ newStatus.ThreadID = replyToStatus.ThreadID
// Mention the replied-to account.
mention := >smodel.Mention{
@@ -324,6 +325,114 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() {
)
}
+func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() {
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_1"]
+
+ // Admin account posts a reply to zork.
+ // Normally zork would get a notification
+ // for this, but zork mutes this thread.
+ status = suite.newStatus(
+ ctx,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ suite.testStatuses["local_account_1_status_1"],
+ nil,
+ )
+ threadMute = >smodel.ThreadMute{
+ ID: "01HD3KRMBB1M85QRWHD912QWRE",
+ ThreadID: suite.testStatuses["local_account_1_status_1"].ThreadID,
+ AccountID: receivingAccount.ID,
+ }
+ )
+
+ // Store the thread mute before processing new status.
+ if err := suite.db.PutThreadMute(ctx, threadMute); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the new status.
+ if err := suite.processor.Workers().ProcessFromClientAPI(
+ ctx,
+ messages.FromClientAPI{
+ APObjectType: ap.ObjectNote,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: status,
+ OriginAccount: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Ensure no notification received.
+ notif, err := suite.db.GetNotification(
+ ctx,
+ gtsmodel.NotificationMention,
+ receivingAccount.ID,
+ postingAccount.ID,
+ status.ID,
+ )
+
+ suite.ErrorIs(err, db.ErrNoEntries)
+ suite.Nil(notif)
+}
+
+func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_1"]
+
+ // Admin account boosts a status by zork.
+ // Normally zork would get a notification
+ // for this, but zork mutes this thread.
+ status = suite.newStatus(
+ ctx,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ suite.testStatuses["local_account_1_status_1"],
+ )
+ threadMute = >smodel.ThreadMute{
+ ID: "01HD3KRMBB1M85QRWHD912QWRE",
+ ThreadID: suite.testStatuses["local_account_1_status_1"].ThreadID,
+ AccountID: receivingAccount.ID,
+ }
+ )
+
+ // Store the thread mute before processing new status.
+ if err := suite.db.PutThreadMute(ctx, threadMute); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the new status.
+ if err := suite.processor.Workers().ProcessFromClientAPI(
+ ctx,
+ messages.FromClientAPI{
+ APObjectType: ap.ActivityAnnounce,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: status,
+ OriginAccount: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Ensure no notification received.
+ notif, err := suite.db.GetNotification(
+ ctx,
+ gtsmodel.NotificationReblog,
+ receivingAccount.ID,
+ postingAccount.ID,
+ status.ID,
+ )
+
+ suite.ErrorIs(err, db.ErrNoEntries)
+ suite.Nil(notif)
+}
+
func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyListOnlyOK() {
// We're modifying the test list so take a copy.
testList := new(gtsmodel.List)
diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go
index 598480cfb..f57235bf1 100644
--- a/internal/processing/workers/fromfediapi.go
+++ b/internal/processing/workers/fromfediapi.go
@@ -315,6 +315,11 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg messages.FromFediAPI) err
return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", fMsg.GTSModel)
}
+ // Ensure fave populated.
+ if err := p.state.DB.PopulateStatusFave(ctx, fave); err != nil {
+ return gtserror.Newf("error populating status fave: %w", err)
+ }
+
if err := p.surface.notifyFave(ctx, fave); err != nil {
return gtserror.Newf("error notifying fave: %w", err)
}
diff --git a/internal/processing/workers/surfacenotify.go b/internal/processing/workers/surfacenotify.go
index 5a4f77a64..b99fa3ad3 100644
--- a/internal/processing/workers/surfacenotify.go
+++ b/internal/processing/workers/surfacenotify.go
@@ -28,15 +28,39 @@
"github.com/superseriousbusiness/gotosocial/internal/id"
)
-// notifyMentions notifies each targeted account in
-// the given mentions that they have a new mention.
+// notifyMentions iterates through mentions on the
+// given status, and notifies each mentioned account
+// that they have a new mention.
func (s *surface) notifyMentions(
ctx context.Context,
- mentions []*gtsmodel.Mention,
+ status *gtsmodel.Status,
) error {
- errs := gtserror.NewMultiError(len(mentions))
+ var (
+ mentions = status.Mentions
+ errs = gtserror.NewMultiError(len(mentions))
+ )
for _, mention := range mentions {
+ // Ensure thread not muted
+ // by mentioned account.
+ muted, err := s.state.DB.IsThreadMutedByAccount(
+ ctx,
+ status.ThreadID,
+ mention.TargetAccountID,
+ )
+
+ if err != nil {
+ errs.Append(err)
+ continue
+ }
+
+ if muted {
+ // This mentioned account
+ // has muted the thread.
+ // Don't pester them.
+ continue
+ }
+
if err := s.notify(
ctx,
gtsmodel.NotificationMention,
@@ -114,6 +138,24 @@ func (s *surface) notifyFave(
return nil
}
+ // Ensure favee hasn't
+ // muted the thread.
+ muted, err := s.state.DB.IsThreadMutedByAccount(
+ ctx,
+ fave.Status.ThreadID,
+ fave.TargetAccountID,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ if muted {
+ // Boostee doesn't want
+ // notifs for this thread.
+ return nil
+ }
+
return s.notify(
ctx,
gtsmodel.NotificationFave,
@@ -134,11 +176,35 @@ func (s *surface) notifyAnnounce(
return nil
}
+ if status.BoostOf == nil {
+ // No boosted status
+ // set, nothing to do.
+ return nil
+ }
+
if status.BoostOfAccountID == status.AccountID {
// Self-boost, nothing to do.
return nil
}
+ // Ensure boostee hasn't
+ // muted the thread.
+ muted, err := s.state.DB.IsThreadMutedByAccount(
+ ctx,
+ status.BoostOf.ThreadID,
+ status.BoostOfAccountID,
+ )
+
+ if err != nil {
+ return err
+ }
+
+ if muted {
+ // Boostee doesn't want
+ // notifs for this thread.
+ return nil
+ }
+
return s.notify(
ctx,
gtsmodel.NotificationReblog,
diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go
index a45c83188..15263cf78 100644
--- a/internal/processing/workers/surfacetimeline.go
+++ b/internal/processing/workers/surfacetimeline.go
@@ -67,7 +67,7 @@ func (s *surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.
}
// Notify each local account that's mentioned by this status.
- if err := s.notifyMentions(ctx, status.Mentions); err != nil {
+ if err := s.notifyMentions(ctx, status); err != nil {
return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err)
}
diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go
index 86acf4595..a99d9e7ae 100644
--- a/internal/typeutils/util.go
+++ b/internal/typeutils/util.go
@@ -52,7 +52,7 @@ func (c *Converter) interactionsWithStatusForAccount(ctx context.Context, s *gts
}
si.Reblogged = reblogged
- muted, err := c.state.DB.IsStatusMutedBy(ctx, s, requestingAccount.ID)
+ muted, err := c.state.DB.IsThreadMutedByAccount(ctx, s.ThreadID, requestingAccount.ID)
if err != nil {
return nil, fmt.Errorf("error checking if requesting account has muted status: %s", err)
}
diff --git a/test/envparsing.sh b/test/envparsing.sh
index 684d008a9..34d00dd80 100755
--- a/test/envparsing.sh
+++ b/test/envparsing.sh
@@ -48,6 +48,7 @@ EXPECT=$(cat << "EOF"
"status-fave-mem-ratio": 2,
"status-mem-ratio": 5,
"tag-mem-ratio": 2,
+ "thread-mute-mem-ratio": 0.2,
"tombstone-mem-ratio": 0.5,
"user-mem-ratio": 0.25,
"visibility-mem-ratio": 2,
diff --git a/testrig/db.go b/testrig/db.go
index 57e94a4bf..771345fe1 100644
--- a/testrig/db.go
+++ b/testrig/db.go
@@ -49,8 +49,10 @@
>smodel.StatusToTag{},
>smodel.StatusFave{},
>smodel.StatusBookmark{},
- >smodel.StatusMute{},
>smodel.Tag{},
+ >smodel.Thread{},
+ >smodel.ThreadMute{},
+ >smodel.ThreadToStatus{},
>smodel.User{},
>smodel.Emoji{},
>smodel.Instance{},
@@ -301,6 +303,18 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
}
}
+ for _, v := range NewTestThreads() {
+ if err := db.Put(ctx, v); err != nil {
+ log.Panic(nil, err)
+ }
+ }
+
+ for _, v := range NewTestThreadToStatus() {
+ if err := db.Put(ctx, v); err != nil {
+ log.Panic(nil, err)
+ }
+ }
+
if err := db.CreateInstanceAccount(ctx); err != nil {
log.Panic(nil, err)
}
diff --git a/testrig/testmodels.go b/testrig/testmodels.go
index 5279ec725..5c9c9259d 100644
--- a/testrig/testmodels.go
+++ b/testrig/testmodels.go
@@ -1318,6 +1318,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWDF2Q4HV5QC161C4TGQ0M3",
ContentWarning: "",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
@@ -1343,6 +1344,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWDQ1C7APSEY34B1HFVHVX7",
ContentWarning: "open to see some puppies",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1370,6 +1372,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
InReplyToAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToURI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
BoostOfID: "",
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
Language: "en",
@@ -1396,6 +1399,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
InReplyToURI: "",
BoostOfID: "01F8MHAMCHF6Y650WCRSCP4WMY",
BoostOfAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
+ ThreadID: "",
ContentWarning: "introduction post",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1420,6 +1424,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
ContentWarning: "introduction post",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1444,6 +1449,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWDVTW3HQWSX66VJQ91Z1RH",
ContentWarning: "",
Visibility: gtsmodel.VisibilityUnlocked,
Sensitive: util.Ptr(false),
@@ -1468,6 +1474,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWDY9PDNHDBDBBFTJKJY8XE",
ContentWarning: "test: you shouldn't be able to interact with this post in any way",
Visibility: gtsmodel.VisibilityMutualsOnly,
Sensitive: util.Ptr(false),
@@ -1493,6 +1500,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE0H2GKH794Q7GDPANH91Q",
ContentWarning: "eye contact, trent reznor gif, cow",
Visibility: gtsmodel.VisibilityMutualsOnly,
Sensitive: util.Ptr(false),
@@ -1518,6 +1526,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE1ERQSMMVWDD0BE491E2P",
ContentWarning: "",
Visibility: gtsmodel.VisibilityFollowersOnly,
Sensitive: util.Ptr(false),
@@ -1542,6 +1551,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE2Q24FWCZE41AS77SDFRZ",
ContentWarning: "introduction post",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1566,6 +1576,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE3P291Z3NJEJVFPW0K9ZQ",
ContentWarning: "",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1590,6 +1601,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE4P0EW9HBA5WHW97D5YV0",
ContentWarning: "you won't be able to like or reply to this",
Visibility: gtsmodel.VisibilityUnlocked,
Sensitive: util.Ptr(true),
@@ -1614,6 +1626,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE5JXFPFP3P5W2QNHVVV27",
ContentWarning: "",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(true),
@@ -1642,6 +1655,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
InReplyToAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
InReplyToURI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
BoostOfID: "",
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
ContentWarning: "",
Visibility: gtsmodel.VisibilityPublic,
Sensitive: util.Ptr(false),
@@ -1669,6 +1683,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
InReplyToAccountID: "",
InReplyToURI: "",
BoostOfID: "",
+ ThreadID: "01HCWE71MGRRDSHBKXFD5DDSWR",
ContentWarning: "",
Visibility: gtsmodel.VisibilityDirect,
Sensitive: util.Ptr(false),
@@ -1695,6 +1710,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
InReplyToID: "",
BoostOfID: "",
+ ThreadID: "01HCWE7ZNC2SS4P05WA5QYED23",
ContentWarning: "",
Visibility: gtsmodel.VisibilityFollowersOnly,
Sensitive: util.Ptr(false),
@@ -1767,6 +1783,111 @@ func NewTestStatusToTags() map[string]*gtsmodel.StatusToTag {
}
}
+func NewTestThreads() map[string]*gtsmodel.Thread {
+ return map[string]*gtsmodel.Thread{
+ "admin_account_status_1": {
+ ID: "01HCWDF2Q4HV5QC161C4TGQ0M3",
+ },
+ "admin_account_status_2": {
+ ID: "01HCWDQ1C7APSEY34B1HFVHVX7",
+ },
+ "local_account_1_status_1": {
+ ID: "01HCWDKKBWECZJQ93E262N36VN",
+ },
+ "local_account_1_status_2": {
+ ID: "01HCWDVTW3HQWSX66VJQ91Z1RH",
+ },
+ "local_account_1_status_3": {
+ ID: "01HCWDY9PDNHDBDBBFTJKJY8XE",
+ },
+ "local_account_1_status_4": {
+ ID: "01HCWE0H2GKH794Q7GDPANH91Q",
+ },
+ "local_account_1_status_5": {
+ ID: "01HCWE1ERQSMMVWDD0BE491E2P",
+ },
+ "local_account_2_status_1": {
+ ID: "01HCWE2Q24FWCZE41AS77SDFRZ",
+ },
+ "local_account_2_status_2": {
+ ID: "01HCWE3P291Z3NJEJVFPW0K9ZQ",
+ },
+ "local_account_2_status_3": {
+ ID: "01HCWE4P0EW9HBA5WHW97D5YV0",
+ },
+ "local_account_2_status_4": {
+ ID: "01HCWE5JXFPFP3P5W2QNHVVV27",
+ },
+ "local_account_2_status_6": {
+ ID: "01HCWE71MGRRDSHBKXFD5DDSWR",
+ },
+ "local_account_2_status_7": {
+ ID: "01HCWE7ZNC2SS4P05WA5QYED23",
+ },
+ }
+}
+
+func NewTestThreadToStatus() []*gtsmodel.ThreadToStatus {
+ return []*gtsmodel.ThreadToStatus{
+ {
+ ThreadID: "01HCWDF2Q4HV5QC161C4TGQ0M3",
+ StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R",
+ },
+ {
+ ThreadID: "01HCWDQ1C7APSEY34B1HFVHVX7",
+ StatusID: "01F8MHAAY43M6RJ473VQFCVH37",
+ },
+ {
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
+ StatusID: "01FF25D5Q0DH7CHD57CTRS6WK0",
+ },
+ {
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
+ StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY",
+ },
+ {
+ ThreadID: "01HCWDVTW3HQWSX66VJQ91Z1RH",
+ StatusID: "01F8MHAYFKS4KMXF8K5Y1C0KRN",
+ },
+ {
+ ThreadID: "01HCWDY9PDNHDBDBBFTJKJY8XE",
+ StatusID: "01F8MHBBN8120SYH7D5S050MGK",
+ },
+ {
+ ThreadID: "01HCWE0H2GKH794Q7GDPANH91Q",
+ StatusID: "01F8MH82FYRXD2RC6108DAJ5HB",
+ },
+ {
+ ThreadID: "01HCWE1ERQSMMVWDD0BE491E2P",
+ StatusID: "01FCTA44PW9H1TB328S9AQXKDS",
+ },
+ {
+ ThreadID: "01HCWE2Q24FWCZE41AS77SDFRZ",
+ StatusID: "01F8MHBQCBTDKN6X5VHGMMN4MA",
+ },
+ {
+ ThreadID: "01HCWE3P291Z3NJEJVFPW0K9ZQ",
+ StatusID: "01F8MHC0H0A7XHTVH5F596ZKBM",
+ },
+ {
+ ThreadID: "01HCWE4P0EW9HBA5WHW97D5YV0",
+ StatusID: "01F8MHC8VWDRBQR0N1BATDDEM5",
+ },
+ {
+ ThreadID: "01HCWDKKBWECZJQ93E262N36VN",
+ StatusID: "01FCQSQ667XHJ9AV9T27SJJSX5",
+ },
+ {
+ ThreadID: "01HCWE71MGRRDSHBKXFD5DDSWR",
+ StatusID: "01FN3VJGFH10KR7S2PB0GFJZYG",
+ },
+ {
+ ThreadID: "01HCWE7ZNC2SS4P05WA5QYED23",
+ StatusID: "01G20ZM733MGN8J344T4ZDDFY1",
+ },
+ }
+}
+
// NewTestMentions returns a map of gts model mentions keyed by their name.
func NewTestMentions() map[string]*gtsmodel.Mention {
return map[string]*gtsmodel.Mention{