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 @@ var Defaults = Configuration{ 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 @@ var registerTables = []interface{}{ >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 @@ import ( "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 @@ package gtsmodel 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 @@ package gtsmodel 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 @@ import ( "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 @@ import ( "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 @@ import ( // 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 @@ const allowedPinnedCount = 10 // - 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 @@ package status 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 @@ import ( ) 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 @@ import ( "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 @@ import ( "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 @@ var testModels = []interface{}{ >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{