diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 22ce536fd..07ff289d7 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -2916,6 +2916,12 @@ definitions:
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users
tag:
properties:
+ following:
+ description: |-
+ Following is true if the user is following this tag, false if they're not,
+ and not present if there is no currently authenticated user.
+ type: boolean
+ x-go-name: Following
history:
description: |-
History of this hashtag's usage.
@@ -6439,7 +6445,7 @@ paths:
- read:accounts
summary: Get an array of all hashtags that you currently have featured on your profile.
tags:
- - featured_tags
+ - tags
/api/v1/filters:
get:
operationId: filtersV1Get
@@ -6834,6 +6840,58 @@ paths:
summary: Reject/deny follow request from the given account ID.
tags:
- follow_requests
+ /api/v1/followed_tags:
+ get:
+ operationId: getFollowedTags
+ parameters:
+ - description: 'Return only followed tags *OLDER* than the given max ID. The followed tag with the specified ID will not be included in the response. NOTE: the ID is of the internal followed tag, NOT a tag name.'
+ in: query
+ name: max_id
+ type: string
+ - description: 'Return only followed tags *NEWER* than the given since ID. The followed tag with the specified ID will not be included in the response. NOTE: the ID is of the internal followed tag, NOT a tag name.'
+ in: query
+ name: since_id
+ type: string
+ - description: 'Return only followed tags *IMMEDIATELY NEWER* than the given min ID. The followed tag with the specified ID will not be included in the response. NOTE: the ID is of the internal followed tag, NOT a tag name.'
+ in: query
+ name: min_id
+ type: string
+ - default: 100
+ description: Number of followed tags to return.
+ in: query
+ maximum: 200
+ minimum: 1
+ name: limit
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: ""
+ headers:
+ Link:
+ description: Links to the next and previous queries.
+ type: string
+ schema:
+ items:
+ $ref: '#/definitions/tag'
+ type: array
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:follows
+ summary: Get an array of all hashtags that you currently follow.
+ tags:
+ - tags
/api/v1/instance:
get:
operationId: instanceGetV1
@@ -9072,6 +9130,103 @@ paths:
summary: Initiate a websocket connection for live streaming of statuses and notifications.
tags:
- streaming
+ /api/v1/tags/{tag_name}:
+ get:
+ description: If the tag does not exist, this method will not create it in the database.
+ operationId: getTag
+ parameters:
+ - description: Name of the tag (no leading `#`)
+ in: path
+ name: tag_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Info about the tag.
+ schema:
+ $ref: '#/definitions/tag'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "404":
+ description: not found
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:follows
+ summary: Get details for a hashtag, including whether you currently follow it.
+ tags:
+ - tags
+ /api/v1/tags/{tag_name}/follow:
+ post:
+ description: 'Idempotent: if you are already following the tag, this call will still succeed.'
+ operationId: followTag
+ parameters:
+ - description: Name of the tag (no leading `#`)
+ in: path
+ name: tag_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Info about the tag.
+ schema:
+ $ref: '#/definitions/tag'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "403":
+ description: forbidden
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:follows
+ summary: Follow a hashtag.
+ tags:
+ - tags
+ /api/v1/tags/{tag_name}/unfollow:
+ post:
+ description: 'Idempotent: if you are not following the tag, this call will still succeed.'
+ operationId: unfollowTag
+ parameters:
+ - description: Name of the tag (no leading `#`)
+ in: path
+ name: tag_name
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Info about the tag.
+ schema:
+ $ref: '#/definitions/tag'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "403":
+ description: forbidden
+ "404":
+ description: unauthorized
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:follows
+ summary: Unfollow a hashtag.
+ tags:
+ - tags
/api/v1/timelines/home:
get:
description: |-
diff --git a/internal/api/client.go b/internal/api/client.go
index b8b226804..18ab9b50b 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -32,6 +32,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
@@ -46,6 +47,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/client/search"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
"github.com/superseriousbusiness/gotosocial/internal/api/client/timelines"
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -59,7 +61,7 @@ type Client struct {
processor *processing.Processor
db db.DB
- accounts *accounts.Module // api/v1/accounts
+ accounts *accounts.Module // api/v1/accounts, api/v1/profile
admin *admin.Module // api/v1/admin
apps *apps.Module // api/v1/apps
blocks *blocks.Module // api/v1/blocks
@@ -71,6 +73,7 @@ type Client struct {
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
+ followedTags *followedtags.Module // api/v1/followed_tags
instance *instance.Module // api/v1/instance
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
lists *lists.Module // api/v1/lists
@@ -84,6 +87,7 @@ type Client struct {
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
streaming *streaming.Module // api/v1/streaming
+ tags *tags.Module // api/v1/tags
timelines *timelines.Module // api/v1/timelines
user *user.Module // api/v1/user
}
@@ -117,6 +121,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV1.Route(h)
c.filtersV2.Route(h)
c.followRequests.Route(h)
+ c.followedTags.Route(h)
c.instance.Route(h)
c.interactionPolicies.Route(h)
c.lists.Route(h)
@@ -130,6 +135,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.search.Route(h)
c.statuses.Route(h)
c.streaming.Route(h)
+ c.tags.Route(h)
c.timelines.Route(h)
c.user.Route(h)
}
@@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
+ followedTags: followedtags.New(p),
instance: instance.New(p),
interactionPolicies: interactionpolicies.New(p),
lists: lists.New(p),
@@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
search: search.New(p),
statuses: statuses.New(p),
streaming: streaming.New(p, time.Second*30, 4096),
+ tags: tags.New(p),
timelines: timelines.New(p),
user: user.New(p),
}
diff --git a/internal/api/client/featuredtags/get.go b/internal/api/client/featuredtags/get.go
index c1ee7ca2c..de47f7ee2 100644
--- a/internal/api/client/featuredtags/get.go
+++ b/internal/api/client/featuredtags/get.go
@@ -34,7 +34,7 @@ import (
//
// ---
// tags:
-// - featured_tags
+// - tags
//
// produces:
// - application/json
diff --git a/internal/api/client/followedtags/followedtags.go b/internal/api/client/followedtags/followedtags.go
new file mode 100644
index 000000000..27fca918d
--- /dev/null
+++ b/internal/api/client/followedtags/followedtags.go
@@ -0,0 +1,43 @@
+// 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 followedtags
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ BasePath = "/v1/followed_tags"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, BasePath, m.FollowedTagsGETHandler)
+}
diff --git a/internal/api/client/followedtags/followedtags_test.go b/internal/api/client/followedtags/followedtags_test.go
new file mode 100644
index 000000000..883ab033b
--- /dev/null
+++ b/internal/api/client/followedtags/followedtags_test.go
@@ -0,0 +1,104 @@
+// 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 followedtags_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type FollowedTagsTestSuite struct {
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testTags map[string]*gtsmodel.Tag
+
+ // module being tested
+ followedTagsModule *followedtags.Module
+}
+
+func (suite *FollowedTagsTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testTags = testrig.NewTestTags()
+}
+
+func (suite *FollowedTagsTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ config.Config(func(cfg *config.Configuration) {
+ cfg.WebAssetBaseDir = "../../../../web/assets/"
+ cfg.WebTemplateBaseDir = "../../../../web/templates/"
+ })
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
+ suite.sentEmails = make(map[string]string)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
+ suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.followedTagsModule = followedtags.New(suite.processor)
+
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *FollowedTagsTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+func TestFollowedTagsTestSuite(t *testing.T) {
+ suite.Run(t, new(FollowedTagsTestSuite))
+}
diff --git a/internal/api/client/followedtags/get.go b/internal/api/client/followedtags/get.go
new file mode 100644
index 000000000..68e4ffb5f
--- /dev/null
+++ b/internal/api/client/followedtags/get.go
@@ -0,0 +1,139 @@
+// 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 followedtags
+
+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"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
+)
+
+// FollowedTagsGETHandler swagger:operation GET /api/v1/followed_tags getFollowedTags
+//
+// Get an array of all hashtags that you currently follow.
+//
+// ---
+// tags:
+// - tags
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:follows
+//
+// parameters:
+// -
+// name: max_id
+// type: string
+// description: >-
+// Return only followed tags *OLDER* than the given max ID.
+// The followed tag with the specified ID will not be included in the response.
+// NOTE: the ID is of the internal followed tag, NOT a tag name.
+// in: query
+// required: false
+// -
+// name: since_id
+// type: string
+// description: >-
+// Return only followed tags *NEWER* than the given since ID.
+// The followed tag with the specified ID will not be included in the response.
+// NOTE: the ID is of the internal followed tag, NOT a tag name.
+// in: query
+// -
+// name: min_id
+// type: string
+// description: >-
+// Return only followed tags *IMMEDIATELY NEWER* than the given min ID.
+// The followed tag with the specified ID will not be included in the response.
+// NOTE: the ID is of the internal followed tag, NOT a tag name.
+// in: query
+// required: false
+// -
+// name: limit
+// type: integer
+// description: Number of followed tags to return.
+// default: 100
+// minimum: 1
+// maximum: 200
+// in: query
+// required: false
+//
+// responses:
+// '200':
+// headers:
+// Link:
+// type: string
+// description: Links to the next and previous queries.
+// schema:
+// type: array
+// items:
+// "$ref": "#/definitions/tag"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) FollowedTagsGETHandler(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
+ }
+
+ page, errWithCode := paging.ParseIDPage(c,
+ 1, // min limit
+ 200, // max limit
+ 100, // default limit
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ resp, errWithCode := m.processor.Tags().Followed(
+ c.Request.Context(),
+ authed.Account.ID,
+ page,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ if resp.LinkHeader != "" {
+ c.Header("Link", resp.LinkHeader)
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp.Items)
+}
diff --git a/internal/api/client/followedtags/get_test.go b/internal/api/client/followedtags/get_test.go
new file mode 100644
index 000000000..bd82c7037
--- /dev/null
+++ b/internal/api/client/followedtags/get_test.go
@@ -0,0 +1,125 @@
+// 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 followedtags_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/followedtags"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+func (suite *FollowedTagsTestSuite) getFollowedTags(
+ accountFixtureName string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) ([]apimodel.Tag, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ ctx.Request = httptest.NewRequest(http.MethodGet, config.GetProtocol()+"://"+config.GetHost()+"/api/"+followedtags.BasePath, nil)
+ ctx.Request.Header.Set("accept", "application/json")
+
+ // trigger the handler
+ suite.followedTagsModule.FollowedTagsGETHandler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ if expectedBody == "" {
+ return nil, errs.Combine()
+ }
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := []apimodel.Tag{}
+ if err := json.Unmarshal(b, &resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+// Test that we can list a user's followed tags.
+func (suite *FollowedTagsTestSuite) TestGet() {
+ accountFixtureName := "local_account_2"
+ testAccount := suite.testAccounts[accountFixtureName]
+ testTag := suite.testTags["welcome"]
+
+ // Follow an existing tag.
+ if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ if suite.Len(followedTags, 1) {
+ followedTag := followedTags[0]
+ suite.Equal(testTag.Name, followedTag.Name)
+ if suite.NotNil(followedTag.Following) {
+ suite.True(*followedTag.Following)
+ }
+ }
+}
+
+// Test that we can list a user's followed tags even if they don't have any.
+func (suite *FollowedTagsTestSuite) TestGetEmpty() {
+ accountFixtureName := "local_account_1"
+
+ followedTags, err := suite.getFollowedTags(accountFixtureName, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Len(followedTags, 0)
+}
diff --git a/internal/api/client/tags/follow.go b/internal/api/client/tags/follow.go
new file mode 100644
index 000000000..2952996b1
--- /dev/null
+++ b/internal/api/client/tags/follow.go
@@ -0,0 +1,92 @@
+// 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 tags
+
+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"
+)
+
+// FollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/follow followTag
+//
+// Follow a hashtag.
+//
+// Idempotent: if you are already following the tag, this call will still succeed.
+//
+// ---
+// tags:
+// - tags
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - write:follows
+//
+// parameters:
+// -
+// name: tag_name
+// type: string
+// description: Name of the tag (no leading `#`)
+// in: path
+// required: true
+//
+// responses:
+// '200':
+// description: "Info about the tag."
+// schema:
+// "$ref": "#/definitions/tag"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '500':
+// description: internal server error
+func (m *Module) FollowTagPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiTag, errWithCode := m.processor.Tags().Follow(c.Request.Context(), authed.Account, name)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiTag)
+}
diff --git a/internal/api/client/tags/follow_test.go b/internal/api/client/tags/follow_test.go
new file mode 100644
index 000000000..d3b08cf5c
--- /dev/null
+++ b/internal/api/client/tags/follow_test.go
@@ -0,0 +1,82 @@
+// 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 tags_test
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+)
+
+func (suite *TagsTestSuite) follow(
+ accountFixtureName string,
+ tagName string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Tag, error) {
+ return suite.tagAction(
+ accountFixtureName,
+ tagName,
+ http.MethodPost,
+ tags.FollowPath,
+ suite.tagsModule.FollowTagPOSTHandler,
+ expectedHTTPStatus,
+ expectedBody,
+ )
+}
+
+// Follow a tag we don't already follow.
+func (suite *TagsTestSuite) TestFollow() {
+ accountFixtureName := "local_account_2"
+ testTag := suite.testTags["welcome"]
+
+ apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.True(*apiTag.Following)
+ }
+}
+
+// When we follow a tag already followed by the account, it should succeed.
+func (suite *TagsTestSuite) TestFollowIdempotent() {
+ accountFixtureName := "local_account_2"
+ testAccount := suite.testAccounts[accountFixtureName]
+ testTag := suite.testTags["welcome"]
+
+ // Setup: follow an existing tag.
+ if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Follow it again through the API.
+ apiTag, err := suite.follow(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.True(*apiTag.Following)
+ }
+}
diff --git a/internal/api/client/tags/get.go b/internal/api/client/tags/get.go
new file mode 100644
index 000000000..b61b7cc65
--- /dev/null
+++ b/internal/api/client/tags/get.go
@@ -0,0 +1,89 @@
+// 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 tags
+
+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"
+)
+
+// TagGETHandler swagger:operation GET /api/v1/tags/{tag_name} getTag
+//
+// Get details for a hashtag, including whether you currently follow it.
+//
+// If the tag does not exist, this method will not create it in the database.
+//
+// ---
+// tags:
+// - tags
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:follows
+//
+// parameters:
+// -
+// name: tag_name
+// type: string
+// description: Name of the tag (no leading `#`)
+// in: path
+// required: true
+//
+// responses:
+// '200':
+// description: "Info about the tag."
+// schema:
+// "$ref": "#/definitions/tag"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '404':
+// description: not found
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) TagGETHandler(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
+ }
+
+ name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiTag, errWithCode := m.processor.Tags().Get(c.Request.Context(), authed.Account, name)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiTag)
+}
diff --git a/internal/api/client/tags/get_test.go b/internal/api/client/tags/get_test.go
new file mode 100644
index 000000000..fa31bce7d
--- /dev/null
+++ b/internal/api/client/tags/get_test.go
@@ -0,0 +1,93 @@
+// 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 tags_test
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+)
+
+// tagAction follows or unfollows a tag.
+func (suite *TagsTestSuite) get(
+ accountFixtureName string,
+ tagName string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Tag, error) {
+ return suite.tagAction(
+ accountFixtureName,
+ tagName,
+ http.MethodGet,
+ tags.TagPath,
+ suite.tagsModule.TagGETHandler,
+ expectedHTTPStatus,
+ expectedBody,
+ )
+}
+
+// Get a tag followed by the account.
+func (suite *TagsTestSuite) TestGetFollowed() {
+ accountFixtureName := "local_account_2"
+ testAccount := suite.testAccounts[accountFixtureName]
+ testTag := suite.testTags["welcome"]
+
+ // Setup: follow an existing tag.
+ if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Get it through the API.
+ apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.True(*apiTag.Following)
+ }
+}
+
+// Get a tag not followed by the account.
+func (suite *TagsTestSuite) TestGetUnfollowed() {
+ accountFixtureName := "local_account_2"
+ testTag := suite.testTags["Hashtag"]
+
+ apiTag, err := suite.get(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.False(*apiTag.Following)
+ }
+}
+
+// Get a tag that does not exist, which should result in a 404.
+func (suite *TagsTestSuite) TestGetNotFound() {
+ accountFixtureName := "local_account_2"
+
+ _, err := suite.get(accountFixtureName, "THIS_TAG_DOES_NOT_EXIST", http.StatusNotFound, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+}
diff --git a/internal/api/client/tags/tags.go b/internal/api/client/tags/tags.go
new file mode 100644
index 000000000..281859547
--- /dev/null
+++ b/internal/api/client/tags/tags.go
@@ -0,0 +1,49 @@
+// 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 tags
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ BasePath = "/v1/tags"
+ TagPath = BasePath + "/:" + apiutil.TagNameKey
+ FollowPath = TagPath + "/follow"
+ UnfollowPath = TagPath + "/unfollow"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, TagPath, m.TagGETHandler)
+ attachHandler(http.MethodPost, FollowPath, m.FollowTagPOSTHandler)
+ attachHandler(http.MethodPost, UnfollowPath, m.UnfollowTagPOSTHandler)
+}
diff --git a/internal/api/client/tags/tags_test.go b/internal/api/client/tags/tags_test.go
new file mode 100644
index 000000000..79c708b10
--- /dev/null
+++ b/internal/api/client/tags/tags_test.go
@@ -0,0 +1,179 @@
+// 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 tags_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/suite"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/email"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/media"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+ "github.com/superseriousbusiness/gotosocial/internal/state"
+ "github.com/superseriousbusiness/gotosocial/internal/storage"
+ "github.com/superseriousbusiness/gotosocial/testrig"
+)
+
+type TagsTestSuite struct {
+ suite.Suite
+ db db.DB
+ storage *storage.Driver
+ mediaManager *media.Manager
+ federator *federation.Federator
+ processor *processing.Processor
+ emailSender email.Sender
+ sentEmails map[string]string
+ state state.State
+
+ // standard suite models
+ testTokens map[string]*gtsmodel.Token
+ testClients map[string]*gtsmodel.Client
+ testApplications map[string]*gtsmodel.Application
+ testUsers map[string]*gtsmodel.User
+ testAccounts map[string]*gtsmodel.Account
+ testTags map[string]*gtsmodel.Tag
+
+ // module being tested
+ tagsModule *tags.Module
+}
+
+func (suite *TagsTestSuite) SetupSuite() {
+ suite.testTokens = testrig.NewTestTokens()
+ suite.testClients = testrig.NewTestClients()
+ suite.testApplications = testrig.NewTestApplications()
+ suite.testUsers = testrig.NewTestUsers()
+ suite.testAccounts = testrig.NewTestAccounts()
+ suite.testTags = testrig.NewTestTags()
+}
+
+func (suite *TagsTestSuite) SetupTest() {
+ suite.state.Caches.Init()
+ testrig.StartNoopWorkers(&suite.state)
+
+ testrig.InitTestConfig()
+ config.Config(func(cfg *config.Configuration) {
+ cfg.WebAssetBaseDir = "../../../../web/assets/"
+ cfg.WebTemplateBaseDir = "../../../../web/templates/"
+ })
+ testrig.InitTestLog()
+
+ suite.db = testrig.NewTestDB(&suite.state)
+ suite.state.DB = suite.db
+ suite.storage = testrig.NewInMemoryStorage()
+ suite.state.Storage = suite.storage
+
+ suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
+ suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
+ suite.sentEmails = make(map[string]string)
+ suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
+ suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
+ suite.tagsModule = tags.New(suite.processor)
+
+ testrig.StandardDBSetup(suite.db, nil)
+ testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
+}
+
+func (suite *TagsTestSuite) TearDownTest() {
+ testrig.StandardDBTeardown(suite.db)
+ testrig.StandardStorageTeardown(suite.storage)
+ testrig.StopWorkers(&suite.state)
+}
+
+// tagAction gets, follows, or unfollows a tag, returning the tag.
+func (suite *TagsTestSuite) tagAction(
+ accountFixtureName string,
+ tagName string,
+ method string,
+ path string,
+ handler func(c *gin.Context),
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Tag, error) {
+ // instantiate recorder + test context
+ recorder := httptest.NewRecorder()
+ ctx, _ := testrig.CreateGinTestContext(recorder, nil)
+ ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts[accountFixtureName])
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens[accountFixtureName]))
+ ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
+ ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers[accountFixtureName])
+
+ // create the request
+ url := config.GetProtocol() + "://" + config.GetHost() + "/api/" + path
+ ctx.Request = httptest.NewRequest(
+ method,
+ strings.Replace(url, ":tag_name", tagName, 1),
+ nil,
+ )
+ ctx.Request.Header.Set("accept", "application/json")
+
+ ctx.AddParam("tag_name", tagName)
+
+ // trigger the handler
+ handler(ctx)
+
+ // read the response
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ b, err := io.ReadAll(result.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ errs := gtserror.NewMultiError(2)
+
+ // check code + body
+ if resultCode := recorder.Code; expectedHTTPStatus != resultCode {
+ errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode)
+ if expectedBody == "" {
+ return nil, errs.Combine()
+ }
+ }
+
+ // if we got an expected body, return early
+ if expectedBody != "" {
+ if string(b) != expectedBody {
+ errs.Appendf("expected %s got %s", expectedBody, string(b))
+ }
+ return nil, errs.Combine()
+ }
+
+ resp := &apimodel.Tag{}
+ if err := json.Unmarshal(b, resp); err != nil {
+ return nil, err
+ }
+
+ return resp, nil
+}
+
+func TestTagsTestSuite(t *testing.T) {
+ suite.Run(t, new(TagsTestSuite))
+}
diff --git a/internal/api/client/tags/unfollow.go b/internal/api/client/tags/unfollow.go
new file mode 100644
index 000000000..3166e08ed
--- /dev/null
+++ b/internal/api/client/tags/unfollow.go
@@ -0,0 +1,94 @@
+// 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 tags
+
+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"
+)
+
+// UnfollowTagPOSTHandler swagger:operation POST /api/v1/tags/{tag_name}/unfollow unfollowTag
+//
+// Unfollow a hashtag.
+//
+// Idempotent: if you are not following the tag, this call will still succeed.
+//
+// ---
+// tags:
+// - tags
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - write:follows
+//
+// parameters:
+// -
+// name: tag_name
+// type: string
+// description: Name of the tag (no leading `#`)
+// in: path
+// required: true
+//
+// responses:
+// '200':
+// description: "Info about the tag."
+// schema:
+// "$ref": "#/definitions/tag"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '403':
+// description: forbidden
+// '404':
+// description: unauthorized
+// '500':
+// description: internal server error
+func (m *Module) UnfollowTagPOSTHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if authed.Account.IsMoving() {
+ apiutil.ForbiddenAfterMove(c)
+ return
+ }
+
+ name, errWithCode := apiutil.ParseTagName(c.Param(apiutil.TagNameKey))
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiTag, errWithCode := m.processor.Tags().Unfollow(c.Request.Context(), authed.Account, name)
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, apiTag)
+}
diff --git a/internal/api/client/tags/unfollow_test.go b/internal/api/client/tags/unfollow_test.go
new file mode 100644
index 000000000..51bc34797
--- /dev/null
+++ b/internal/api/client/tags/unfollow_test.go
@@ -0,0 +1,82 @@
+// 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 tags_test
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/tags"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+)
+
+func (suite *TagsTestSuite) unfollow(
+ accountFixtureName string,
+ tagName string,
+ expectedHTTPStatus int,
+ expectedBody string,
+) (*apimodel.Tag, error) {
+ return suite.tagAction(
+ accountFixtureName,
+ tagName,
+ http.MethodPost,
+ tags.UnfollowPath,
+ suite.tagsModule.UnfollowTagPOSTHandler,
+ expectedHTTPStatus,
+ expectedBody,
+ )
+}
+
+// Unfollow a tag that we follow.
+func (suite *TagsTestSuite) TestUnfollow() {
+ accountFixtureName := "local_account_2"
+ testAccount := suite.testAccounts[accountFixtureName]
+ testTag := suite.testTags["welcome"]
+
+ // Setup: follow an existing tag.
+ if err := suite.db.PutFollowedTag(context.Background(), testAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Unfollow it through the API.
+ apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.False(*apiTag.Following)
+ }
+}
+
+// When we unfollow a tag not followed by the account, it should succeed.
+func (suite *TagsTestSuite) TestUnfollowIdempotent() {
+ accountFixtureName := "local_account_2"
+ testTag := suite.testTags["Hashtag"]
+
+ apiTag, err := suite.unfollow(accountFixtureName, testTag.Name, http.StatusOK, "")
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(testTag.Name, apiTag.Name)
+ if suite.NotNil(apiTag.Following) {
+ suite.False(*apiTag.Following)
+ }
+}
diff --git a/internal/api/model/tag.go b/internal/api/model/tag.go
index ebc12e2d4..4f81bac6f 100644
--- a/internal/api/model/tag.go
+++ b/internal/api/model/tag.go
@@ -31,4 +31,7 @@ type Tag struct {
// Currently just a stub, if provided will always be an empty array.
// example: []
History *[]any `json:"history,omitempty"`
+ // Following is true if the user is following this tag, false if they're not,
+ // and not present if there is no currently authenticated user.
+ Following *bool `json:"following,omitempty"`
}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 8187ba419..2949d528a 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -57,6 +57,7 @@ func (c *Caches) Init() {
log.Infof(nil, "init: %p", c)
c.initAccount()
+ c.initAccountIDsFollowingTag()
c.initAccountNote()
c.initAccountSettings()
c.initAccountStats()
@@ -98,6 +99,7 @@ func (c *Caches) Init() {
c.initStatusFave()
c.initStatusFaveIDs()
c.initTag()
+ c.initTagIDsFollowedByAccount()
c.initThreadMute()
c.initToken()
c.initTombstone()
@@ -134,6 +136,7 @@ func (c *Caches) Stop() {
// significant overhead to all cache writes.
func (c *Caches) Sweep(threshold float64) {
c.DB.Account.Trim(threshold)
+ c.DB.AccountIDsFollowingTag.Trim(threshold)
c.DB.AccountNote.Trim(threshold)
c.DB.AccountSettings.Trim(threshold)
c.DB.AccountStats.Trim(threshold)
@@ -142,6 +145,8 @@ func (c *Caches) Sweep(threshold float64) {
c.DB.BlockIDs.Trim(threshold)
c.DB.BoostOfIDs.Trim(threshold)
c.DB.Client.Trim(threshold)
+ c.DB.Conversation.Trim(threshold)
+ c.DB.ConversationLastStatusIDs.Trim(threshold)
c.DB.Emoji.Trim(threshold)
c.DB.EmojiCategory.Trim(threshold)
c.DB.Filter.Trim(threshold)
@@ -171,6 +176,7 @@ func (c *Caches) Sweep(threshold float64) {
c.DB.StatusFave.Trim(threshold)
c.DB.StatusFaveIDs.Trim(threshold)
c.DB.Tag.Trim(threshold)
+ c.DB.TagIDsFollowedByAccount.Trim(threshold)
c.DB.ThreadMute.Trim(threshold)
c.DB.Token.Trim(threshold)
c.DB.Tombstone.Trim(threshold)
diff --git a/internal/cache/db.go b/internal/cache/db.go
index 16e1d286a..c1b87ef96 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -29,6 +29,9 @@ type DBCaches struct {
// Account provides access to the gtsmodel Account database cache.
Account StructCache[*gtsmodel.Account]
+ // AccountIDsFollowingTag caches account IDs following a given tag ID.
+ AccountIDsFollowingTag SliceCache[string]
+
// AccountNote provides access to the gtsmodel Note database cache.
AccountNote StructCache[*gtsmodel.AccountNote]
@@ -160,6 +163,9 @@ type DBCaches struct {
// Tag provides access to the gtsmodel Tag database cache.
Tag StructCache[*gtsmodel.Tag]
+ // TagIDsFollowedByAccount caches tag IDs followed by a given account ID.
+ TagIDsFollowedByAccount SliceCache[string]
+
// ThreadMute provides access to the gtsmodel ThreadMute database cache.
ThreadMute StructCache[*gtsmodel.ThreadMute]
@@ -234,6 +240,17 @@ func (c *Caches) initAccount() {
})
}
+func (c *Caches) initAccountIDsFollowingTag() {
+ // Calculate maximum cache size.
+ cap := calculateSliceCacheMax(
+ config.GetCacheAccountIDsFollowingTagMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ c.DB.AccountIDsFollowingTag.Init(0, cap)
+}
+
func (c *Caches) initAccountNote() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
@@ -1317,6 +1334,17 @@ func (c *Caches) initTag() {
})
}
+func (c *Caches) initTagIDsFollowedByAccount() {
+ // Calculate maximum cache size.
+ cap := calculateSliceCacheMax(
+ config.GetCacheTagIDsFollowedByAccountMemRatio(),
+ )
+
+ log.Infof(nil, "cache size = %d", cap)
+
+ c.DB.TagIDsFollowedByAccount.Init(0, cap)
+}
+
func (c *Caches) initThreadMute() {
cap := calculateResultCacheMax(
sizeofThreadMute(), // model in-mem size.
diff --git a/internal/config/config.go b/internal/config/config.go
index 1b8cf2759..a6499d822 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -193,6 +193,7 @@ type HTTPClientConfiguration struct {
type CacheConfiguration struct {
MemoryTarget bytesize.Size `name:"memory-target"`
AccountMemRatio float64 `name:"account-mem-ratio"`
+ AccountIDsFollowingTagMemRatio float64 `name:"account-ids-following-tag-mem-ratio"`
AccountNoteMemRatio float64 `name:"account-note-mem-ratio"`
AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"`
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
@@ -232,6 +233,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"`
+ TagIDsFollowedByAccountMemRatio float64 `name:"tag-ids-followed-by-account-mem-ratio"`
ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"`
TokenMemRatio float64 `name:"token-mem-ratio"`
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index 82ea07e10..835841c84 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -157,6 +157,7 @@ var Defaults = Configuration{
// file have been addressed, these should
// be able to make some more sense :D
AccountMemRatio: 5,
+ AccountIDsFollowingTagMemRatio: 1,
AccountNoteMemRatio: 1,
AccountSettingsMemRatio: 0.1,
AccountStatsMemRatio: 2,
@@ -196,6 +197,7 @@ var Defaults = Configuration{
StatusFaveMemRatio: 2,
StatusFaveIDsMemRatio: 3,
TagMemRatio: 2,
+ TagIDsFollowedByAccountMemRatio: 1,
ThreadMuteMemRatio: 0.2,
TokenMemRatio: 0.75,
TombstoneMemRatio: 0.5,
diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go
index 932cb802d..587fba364 100644
--- a/internal/config/helpers.gen.go
+++ b/internal/config/helpers.gen.go
@@ -2775,6 +2775,37 @@ func GetCacheAccountMemRatio() float64 { return global.GetCacheAccountMemRatio()
// SetCacheAccountMemRatio safely sets the value for global configuration 'Cache.AccountMemRatio' field
func SetCacheAccountMemRatio(v float64) { global.SetCacheAccountMemRatio(v) }
+// GetCacheAccountIDsFollowingTagMemRatio safely fetches the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field
+func (st *ConfigState) GetCacheAccountIDsFollowingTagMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.AccountIDsFollowingTagMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheAccountIDsFollowingTagMemRatio safely sets the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field
+func (st *ConfigState) SetCacheAccountIDsFollowingTagMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.AccountIDsFollowingTagMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheAccountIDsFollowingTagMemRatioFlag returns the flag name for the 'Cache.AccountIDsFollowingTagMemRatio' field
+func CacheAccountIDsFollowingTagMemRatioFlag() string {
+ return "cache-account-ids-following-tag-mem-ratio"
+}
+
+// GetCacheAccountIDsFollowingTagMemRatio safely fetches the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field
+func GetCacheAccountIDsFollowingTagMemRatio() float64 {
+ return global.GetCacheAccountIDsFollowingTagMemRatio()
+}
+
+// SetCacheAccountIDsFollowingTagMemRatio safely sets the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field
+func SetCacheAccountIDsFollowingTagMemRatio(v float64) {
+ global.SetCacheAccountIDsFollowingTagMemRatio(v)
+}
+
// GetCacheAccountNoteMemRatio safely fetches the Configuration value for state's 'Cache.AccountNoteMemRatio' field
func (st *ConfigState) GetCacheAccountNoteMemRatio() (v float64) {
st.mutex.RLock()
@@ -3758,6 +3789,37 @@ func GetCacheTagMemRatio() float64 { return global.GetCacheTagMemRatio() }
// SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field
func SetCacheTagMemRatio(v float64) { global.SetCacheTagMemRatio(v) }
+// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field
+func (st *ConfigState) GetCacheTagIDsFollowedByAccountMemRatio() (v float64) {
+ st.mutex.RLock()
+ v = st.config.Cache.TagIDsFollowedByAccountMemRatio
+ st.mutex.RUnlock()
+ return
+}
+
+// SetCacheTagIDsFollowedByAccountMemRatio safely sets the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field
+func (st *ConfigState) SetCacheTagIDsFollowedByAccountMemRatio(v float64) {
+ st.mutex.Lock()
+ defer st.mutex.Unlock()
+ st.config.Cache.TagIDsFollowedByAccountMemRatio = v
+ st.reloadToViper()
+}
+
+// CacheTagIDsFollowedByAccountMemRatioFlag returns the flag name for the 'Cache.TagIDsFollowedByAccountMemRatio' field
+func CacheTagIDsFollowedByAccountMemRatioFlag() string {
+ return "cache-tag-ids-followed-by-account-mem-ratio"
+}
+
+// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field
+func GetCacheTagIDsFollowedByAccountMemRatio() float64 {
+ return global.GetCacheTagIDsFollowedByAccountMemRatio()
+}
+
+// SetCacheTagIDsFollowedByAccountMemRatio safely sets the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field
+func SetCacheTagIDsFollowedByAccountMemRatio(v float64) {
+ global.SetCacheTagIDsFollowedByAccountMemRatio(v)
+}
+
// GetCacheThreadMuteMemRatio safely fetches the Configuration value for state's 'Cache.ThreadMuteMemRatio' field
func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) {
st.mutex.RLock()
diff --git a/internal/db/bundb/migrations/20240725211933_add_followed_tags.go b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go
new file mode 100644
index 000000000..f86b7d070
--- /dev/null
+++ b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go
@@ -0,0 +1,51 @@
+// 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"
+
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ if _, err := tx.
+ NewCreateTable().
+ Model(>smodel.FollowedTag{}).
+ IfNotExists().
+ Exec(ctx); err != nil {
+ return err
+ }
+
+ return nil
+ })
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/db/bundb/tag.go b/internal/db/bundb/tag.go
index 5218a19d5..c6298ee64 100644
--- a/internal/db/bundb/tag.go
+++ b/internal/db/bundb/tag.go
@@ -19,9 +19,13 @@ package bundb
import (
"context"
+ "errors"
"strings"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/uptrace/bun"
@@ -131,3 +135,158 @@ func (t *tagDB) PutTag(ctx context.Context, tag *gtsmodel.Tag) error {
return nil
}
+
+func (t *tagDB) GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) {
+ tagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, page)
+ if err != nil {
+ return nil, err
+ }
+
+ tags, err := t.GetTags(ctx, tagIDs)
+ if err != nil {
+ return nil, err
+ }
+
+ return tags, nil
+}
+
+func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
+ return loadPagedIDs(&t.state.Caches.DB.TagIDsFollowedByAccount, accountID, page, func() ([]string, error) {
+ var tagIDs []string
+
+ // Tag IDs not in cache. Perform DB query.
+ if _, err := t.db.
+ NewSelect().
+ Model((*gtsmodel.FollowedTag)(nil)).
+ Column("tag_id").
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ OrderExpr("? DESC", bun.Ident("tag_id")).
+ Exec(ctx, &tagIDs); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.Newf("error getting tag IDs followed by account %s: %w", accountID, err)
+ }
+
+ return tagIDs, nil
+ })
+}
+
+func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]string, error) {
+ return loadPagedIDs(&t.state.Caches.DB.AccountIDsFollowingTag, tagID, nil, func() ([]string, error) {
+ var accountIDs []string
+
+ // Account IDs not in cache. Perform DB query.
+ if _, err := t.db.
+ NewSelect().
+ Model((*gtsmodel.FollowedTag)(nil)).
+ Column("account_id").
+ Where("? = ?", bun.Ident("tag_id"), tagID).
+ OrderExpr("? DESC", bun.Ident("account_id")).
+ Exec(ctx, &accountIDs); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.Newf("error getting account IDs following tag %s: %w", tagID, err)
+ }
+
+ return accountIDs, nil
+ })
+}
+
+func (t *tagDB) IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) {
+ accountTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil)
+ if err != nil {
+ return false, err
+ }
+
+ for _, accountTagID := range accountTagIDs {
+ if accountTagID == tagID {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID string) error {
+ // Insert the followed tag.
+ result, err := t.db.NewInsert().
+ Model(>smodel.FollowedTag{
+ AccountID: accountID,
+ TagID: tagID,
+ }).
+ On("CONFLICT (?, ?) DO NOTHING", bun.Ident("account_id"), bun.Ident("tag_id")).
+ Exec(ctx)
+ if err != nil {
+ return gtserror.Newf("error inserting followed tag: %w", err)
+ }
+
+ // If it fails because that account already follows that tag, that's fine, and we're done.
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return gtserror.Newf("error getting inserted row count: %w", err)
+ }
+ if rows == 0 {
+ return nil
+ }
+
+ // Otherwise, this is a new followed tag, so we invalidate caches related to it.
+ t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
+ t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
+
+ return nil
+}
+
+func (t *tagDB) DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error {
+ result, err := t.db.NewDelete().
+ Model((*gtsmodel.FollowedTag)(nil)).
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Where("? = ?", bun.Ident("tag_id"), tagID).
+ Exec(ctx)
+ if err != nil {
+ return gtserror.Newf("error deleting followed tag %s for account %s: %w", tagID, accountID, err)
+ }
+
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return gtserror.Newf("error getting inserted row count: %w", err)
+ }
+ if rows == 0 {
+ return nil
+ }
+
+ // If we deleted anything, invalidate caches related to it.
+ t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
+ t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
+
+ return err
+}
+
+func (t *tagDB) DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error {
+ // Delete followed tags from the database, returning the list of tag IDs affected.
+ tagIDs := []string{}
+ if err := t.db.NewDelete().
+ Model((*gtsmodel.FollowedTag)(nil)).
+ Where("? = ?", bun.Ident("account_id"), accountID).
+ Returning("?", bun.Ident("tag_id")).
+ Scan(ctx, &tagIDs); // nocollapse
+ err != nil {
+ return gtserror.Newf("error deleting followed tags for account %s: %w", accountID, err)
+ }
+
+ // Invalidate account ID caches for the account and those tags.
+ t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
+ t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagIDs...)
+
+ return nil
+}
+
+func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) {
+ // Accounts might be following multiple tags in this list, but we only want to return each account once.
+ accountIDs := []string{}
+ for _, tagID := range tagIDs {
+ tagAccountIDs, err := t.getAccountIDsFollowingTag(ctx, tagID)
+ if err != nil {
+ return nil, err
+ }
+ accountIDs = append(accountIDs, tagAccountIDs...)
+ }
+ return util.UniqueStrings(accountIDs), nil
+}
diff --git a/internal/db/tag.go b/internal/db/tag.go
index c0642f5a4..66c880e86 100644
--- a/internal/db/tag.go
+++ b/internal/db/tag.go
@@ -21,6 +21,7 @@ import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
)
// Tag contains functions for getting/creating tags in the database.
@@ -36,4 +37,24 @@ type Tag interface {
// GetTags gets multiple tags.
GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, error)
+
+ // GetFollowedTags gets the user's followed tags.
+ GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error)
+
+ // IsAccountFollowingTag returns whether the account follows the given tag.
+ IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error)
+
+ // PutFollowedTag creates a new followed tag for a the given user.
+ // If it already exists, it returns without an error.
+ PutFollowedTag(ctx context.Context, accountID string, tagID string) error
+
+ // DeleteFollowedTag deletes a followed tag for a the given user.
+ // If no such followed tag exists, it returns without an error.
+ DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error
+
+ // DeleteFollowedTagsByAccountID deletes all of an account's followed tags.
+ DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error
+
+ // GetAccountIDsFollowingTagIDs returns the account IDs of any followers of the given tag IDs.
+ GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error)
}
diff --git a/internal/gtsmodel/tag.go b/internal/gtsmodel/tag.go
index 470bee094..0b66585eb 100644
--- a/internal/gtsmodel/tag.go
+++ b/internal/gtsmodel/tag.go
@@ -29,3 +29,12 @@ type Tag struct {
Listable *bool `bun:",nullzero,notnull,default:true"` // Tagged statuses can be listed on this instance.
Href string `bun:"-"` // Href of the hashtag. Will only be set on freshly-extracted hashtags from remote AP messages. Not stored in the database.
}
+
+// FollowedTag represents a user following a tag.
+type FollowedTag struct {
+ // ID of the account that follows the tag.
+ AccountID string `bun:"type:CHAR(26),pk,nullzero"`
+
+ // ID of the tag.
+ TagID string `bun:"type:CHAR(26),pk,nullzero"`
+}
diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go
index 702b46cda..c8d1ba5f9 100644
--- a/internal/processing/account/delete.go
+++ b/internal/processing/account/delete.go
@@ -474,6 +474,12 @@ func (p *Processor) deleteAccountPeripheral(ctx context.Context, account *gtsmod
return gtserror.Newf("error deleting poll votes by account: %w", err)
}
+ // Delete all followed tags owned by given account.
+ if err := p.state.DB.DeleteFollowedTagsByAccountID(ctx, account.ID); // nocollapse
+ err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return gtserror.Newf("error deleting followed tags by account: %w", err)
+ }
+
// Delete account stats model.
if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil {
return gtserror.Newf("error deleting stats for account: %w", err)
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 0afe8356b..6d39dc103 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -42,6 +42,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/search"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/tags"
"github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
"github.com/superseriousbusiness/gotosocial/internal/processing/workers"
@@ -88,6 +89,7 @@ type Processor struct {
search search.Processor
status status.Processor
stream stream.Processor
+ tags tags.Processor
timeline timeline.Processor
user user.Processor
workers workers.Processor
@@ -153,6 +155,10 @@ func (p *Processor) Stream() *stream.Processor {
return &p.stream
}
+func (p *Processor) Tags() *tags.Processor {
+ return &p.tags
+}
+
func (p *Processor) Timeline() *timeline.Processor {
return &p.timeline
}
@@ -207,6 +213,7 @@ func NewProcessor(
processor.markers = markers.New(state, converter)
processor.polls = polls.New(&common, state, converter)
processor.report = report.New(state, converter)
+ processor.tags = tags.New(state, converter)
processor.timeline = timeline.New(state, converter, visFilter)
processor.search = search.New(state, federator, converter, visFilter)
processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc)
diff --git a/internal/processing/search/util.go b/internal/processing/search/util.go
index 8043affd9..e2947ea16 100644
--- a/internal/processing/search/util.go
+++ b/internal/processing/search/util.go
@@ -146,7 +146,7 @@ func (p *Processor) packageHashtags(
} else {
// If API not version 1, provide slice of full tags.
rangeF = func(tag *gtsmodel.Tag) {
- apiTag, err := p.converter.TagToAPITag(ctx, tag, true)
+ apiTag, err := p.converter.TagToAPITag(ctx, tag, true, nil)
if err != nil {
log.Debugf(
ctx,
diff --git a/internal/processing/tags/follow.go b/internal/processing/tags/follow.go
new file mode 100644
index 000000000..f840f4bb7
--- /dev/null
+++ b/internal/processing/tags/follow.go
@@ -0,0 +1,67 @@
+// 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 tags
+
+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"
+)
+
+// Follow follows the tag with the given name as the given account.
+// If there is no tag with that name, it creates a tag.
+func (p *Processor) Follow(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ name string,
+) (*apimodel.Tag, gtserror.WithCode) {
+ // Try to get an existing tag with that name.
+ tag, err := p.state.DB.GetTagByName(ctx, name)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error getting tag with name %s: %w", name, err),
+ )
+ }
+
+ // If there is no such tag, create it.
+ if tag == nil {
+ tag = >smodel.Tag{
+ ID: id.NewULID(),
+ Name: name,
+ }
+ if err := p.state.DB.PutTag(ctx, tag); err != nil {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error creating tag with name %s: %w", name, err),
+ )
+ }
+ }
+
+ // Follow the tag.
+ if err := p.state.DB.PutFollowedTag(ctx, account.ID, tag.ID); err != nil {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error following tag %s: %w", tag.ID, err),
+ )
+ }
+
+ return p.apiTag(ctx, tag, true)
+}
diff --git a/internal/processing/tags/followed.go b/internal/processing/tags/followed.go
new file mode 100644
index 000000000..b9c450653
--- /dev/null
+++ b/internal/processing/tags/followed.go
@@ -0,0 +1,73 @@
+// 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 tags
+
+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/log"
+ "github.com/superseriousbusiness/gotosocial/internal/paging"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+// Followed gets the user's list of followed tags.
+func (p *Processor) Followed(
+ ctx context.Context,
+ accountID string,
+ page *paging.Page,
+) (*apimodel.PageableResponse, gtserror.WithCode) {
+ tags, err := p.state.DB.GetFollowedTags(ctx,
+ accountID,
+ page,
+ )
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error getting followed tags for account %s: %w", accountID, err),
+ )
+ }
+
+ count := len(tags)
+ if len(tags) == 0 {
+ return util.EmptyPageableResponse(), nil
+ }
+
+ lo := tags[count-1].ID
+ hi := tags[0].ID
+
+ items := make([]interface{}, 0, count)
+ following := util.Ptr(true)
+ for _, tag := range tags {
+ apiTag, err := p.converter.TagToAPITag(ctx, tag, true, following)
+ if err != nil {
+ log.Errorf(ctx, "error converting tag %s to API representation: %v", tag.ID, err)
+ continue
+ }
+ items = append(items, apiTag)
+ }
+
+ return paging.PackageResponse(paging.ResponseParams{
+ Items: items,
+ Path: "/api/v1/followed_tags",
+ Next: page.Next(lo, hi),
+ Prev: page.Prev(lo, hi),
+ }), nil
+}
diff --git a/internal/processing/tags/followedtags.go b/internal/processing/tags/followedtags.go
new file mode 100644
index 000000000..c9093a6c6
--- /dev/null
+++ b/internal/processing/tags/followedtags.go
@@ -0,0 +1,53 @@
+// 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 tags
+
+import (
+ "context"
+
+ 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/state"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+type Processor struct {
+ state *state.State
+ converter *typeutils.Converter
+}
+
+func New(state *state.State, converter *typeutils.Converter) Processor {
+ return Processor{
+ state: state,
+ converter: converter,
+ }
+}
+
+// apiTag is a shortcut to return the API version of the given tag,
+// or return an appropriate error if conversion fails.
+func (p *Processor) apiTag(ctx context.Context, tag *gtsmodel.Tag, following bool) (*apimodel.Tag, gtserror.WithCode) {
+ apiTag, err := p.converter.TagToAPITag(ctx, tag, true, &following)
+ if err != nil {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("error converting tag %s to API representation: %w", tag.Name, err),
+ )
+ }
+
+ return &apiTag, nil
+}
diff --git a/internal/processing/tags/get.go b/internal/processing/tags/get.go
new file mode 100644
index 000000000..c8fa66137
--- /dev/null
+++ b/internal/processing/tags/get.go
@@ -0,0 +1,57 @@
+// 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 tags
+
+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"
+)
+
+// Get gets the tag with the given name, including whether it's followed by the given account.
+func (p *Processor) Get(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ name string,
+) (*apimodel.Tag, gtserror.WithCode) {
+ // Try to get an existing tag with that name.
+ tag, err := p.state.DB.GetTagByName(ctx, name)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error getting tag with name %s: %w", name, err),
+ )
+ }
+ if tag == nil {
+ return nil, gtserror.NewErrorNotFound(
+ gtserror.Newf("couldn't find tag with name %s: %w", name, err),
+ )
+ }
+
+ following, err := p.state.DB.IsAccountFollowingTag(ctx, account.ID, tag.ID)
+ if err != nil {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error checking whether account %s follows tag %s: %w", account.ID, tag.ID, err),
+ )
+ }
+
+ return p.apiTag(ctx, tag, following)
+}
diff --git a/internal/processing/tags/unfollow.go b/internal/processing/tags/unfollow.go
new file mode 100644
index 000000000..fb844cd9f
--- /dev/null
+++ b/internal/processing/tags/unfollow.go
@@ -0,0 +1,58 @@
+// 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 tags
+
+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"
+)
+
+// Unfollow unfollows the tag with the given name as the given account.
+// If there is no tag with that name, it creates a tag.
+func (p *Processor) Unfollow(
+ ctx context.Context,
+ account *gtsmodel.Account,
+ name string,
+) (*apimodel.Tag, gtserror.WithCode) {
+ // Try to get an existing tag with that name.
+ tag, err := p.state.DB.GetTagByName(ctx, name)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error getting tag with name %s: %w", name, err),
+ )
+ }
+ if tag == nil {
+ return nil, gtserror.NewErrorNotFound(
+ gtserror.Newf("couldn't find tag with name %s: %w", name, err),
+ )
+ }
+
+ // Unfollow the tag.
+ if err := p.state.DB.DeleteFollowedTag(ctx, account.ID, tag.ID); err != nil {
+ return nil, gtserror.NewErrorInternalError(
+ gtserror.Newf("DB error unfollowing tag %s: %w", tag.ID, err),
+ )
+ }
+
+ return p.apiTag(ctx, tag, false)
+}
diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go
index 35c2c31b7..b4eae0be0 100644
--- a/internal/processing/workers/fromclientapi_test.go
+++ b/internal/processing/workers/fromclientapi_test.go
@@ -52,6 +52,7 @@ func (suite *FromClientAPITestSuite) newStatus(
boostOfStatus *gtsmodel.Status,
mentionedAccounts []*gtsmodel.Account,
createThread bool,
+ tagIDs []string,
) *gtsmodel.Status {
var (
protocol = config.GetProtocol()
@@ -65,6 +66,7 @@ func (suite *FromClientAPITestSuite) newStatus(
URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID,
URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID,
Content: "pee pee poo poo",
+ TagIDs: tagIDs,
Local: util.Ptr(true),
AccountURI: account.URI,
AccountID: account.ID,
@@ -256,6 +258,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
nil,
nil,
false,
+ nil,
)
)
@@ -367,6 +370,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() {
nil,
nil,
false,
+ nil,
)
)
@@ -428,6 +432,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() {
nil,
nil,
false,
+ nil,
)
threadMute = >smodel.ThreadMute{
ID: "01HD3KRMBB1M85QRWHD912QWRE",
@@ -488,6 +493,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
suite.testStatuses["local_account_1_status_1"],
nil,
false,
+ nil,
)
threadMute = >smodel.ThreadMute{
ID: "01HD3KRMBB1M85QRWHD912QWRE",
@@ -553,6 +559,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
nil,
nil,
false,
+ nil,
)
)
@@ -628,6 +635,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
nil,
nil,
false,
+ nil,
)
)
@@ -708,6 +716,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli
nil,
nil,
false,
+ nil,
)
)
@@ -780,6 +789,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() {
suite.testStatuses["local_account_2_status_1"],
nil,
false,
+ nil,
)
)
@@ -843,6 +853,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() {
suite.testStatuses["local_account_2_status_1"],
nil,
false,
+ nil,
)
)
@@ -912,6 +923,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat
nil,
[]*gtsmodel.Account{receivingAccount},
true,
+ nil,
)
)
@@ -997,6 +1009,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate
nil,
[]*gtsmodel.Account{receivingAccount},
true,
+ nil,
)
)
@@ -1038,6 +1051,555 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate
)
}
+// A public status with a hashtag followed by a local user who does not otherwise follow the author
+// should end up in the tag-following user's home timeline.
+func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block postingAccount or vice versa.
+ blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the new status.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ObjectNote,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: status,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ true,
+ "",
+ stream.EventTypeUpdate,
+ )
+}
+
+// A public status with a hashtag followed by a local user who does not otherwise follow the author
+// should not end up in the tag-following user's home timeline
+// if the user has the author blocked.
+func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagAndBlock() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["remote_account_1"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: postingAccount does not block receivingAccount.
+ blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Check precondition: receivingAccount blocks postingAccount.
+ blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.True(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the new status.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ObjectNote,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: status,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ false,
+ "",
+ "",
+ )
+}
+
+// A boost of a public status with a hashtag followed by a local user
+// who does not otherwise follow the author or booster
+// should end up in the tag-following user's home timeline as the original status.
+func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["remote_account_2"]
+ boostingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+
+ // boostingAccount boosts that status.
+ boost = suite.newStatus(
+ ctx,
+ testStructs.State,
+ boostingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ status,
+ nil,
+ false,
+ nil,
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block postingAccount or vice versa.
+ blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Check precondition: receivingAccount does not follow boostingAccount.
+ following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block boostingAccount or vice versa.
+ blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the boost.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ActivityAnnounce,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: boost,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ true,
+ "",
+ stream.EventTypeUpdate,
+ )
+}
+
+// A boost of a public status with a hashtag followed by a local user
+// who does not otherwise follow the author or booster
+// should not end up in the tag-following user's home timeline
+// if the user has the author blocked.
+func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlock() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["remote_account_1"]
+ boostingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+
+ // boostingAccount boosts that status.
+ boost = suite.newStatus(
+ ctx,
+ testStructs.State,
+ boostingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ status,
+ nil,
+ false,
+ nil,
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: postingAccount does not block receivingAccount.
+ blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Check precondition: receivingAccount blocks postingAccount.
+ blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.True(blocking)
+
+ // Check precondition: receivingAccount does not follow boostingAccount.
+ following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block boostingAccount or vice versa.
+ blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the boost.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ActivityAnnounce,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: boost,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ false,
+ "",
+ "",
+ )
+}
+
+// A boost of a public status with a hashtag followed by a local user
+// who does not otherwise follow the author or booster
+// should not end up in the tag-following user's home timeline
+// if the user has the booster blocked.
+func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlockedBoost() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["admin_account"]
+ boostingAccount = suite.testAccounts["remote_account_1"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+
+ // boostingAccount boosts that status.
+ boost = suite.newStatus(
+ ctx,
+ testStructs.State,
+ boostingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ status,
+ nil,
+ false,
+ nil,
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block postingAccount or vice versa.
+ blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Check precondition: receivingAccount does not follow boostingAccount.
+ following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: boostingAccount does not block receivingAccount.
+ blocking, err = testStructs.State.DB.IsBlocked(ctx, boostingAccount.ID, receivingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Check precondition: receivingAccount blocks boostingAccount.
+ blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, boostingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.True(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Process the boost.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ActivityAnnounce,
+ APActivityType: ap.ActivityCreate,
+ GTSModel: boost,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ false,
+ "",
+ "",
+ )
+}
+
+// Updating a public status with a hashtag followed by a local user who does not otherwise follow the author
+// should stream a status update to the tag-following user's home timeline.
+func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() {
+ testStructs := suite.SetupTestStructs()
+ defer suite.TearDownTestStructs(testStructs)
+
+ var (
+ ctx = context.Background()
+ postingAccount = suite.testAccounts["admin_account"]
+ receivingAccount = suite.testAccounts["local_account_2"]
+ streams = suite.openStreams(ctx,
+ testStructs.Processor,
+ receivingAccount,
+ nil,
+ )
+ homeStream = streams[stream.TimelineHome]
+ testTag = suite.testTags["welcome"]
+
+ // postingAccount posts a new public status not mentioning anyone but using testTag.
+ status = suite.newStatus(
+ ctx,
+ testStructs.State,
+ postingAccount,
+ gtsmodel.VisibilityPublic,
+ nil,
+ nil,
+ nil,
+ false,
+ []string{testTag.ID},
+ )
+ )
+
+ // Check precondition: receivingAccount does not follow postingAccount.
+ following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(following)
+
+ // Check precondition: receivingAccount does not block postingAccount or vice versa.
+ blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(blocking)
+
+ // Setup: receivingAccount follows testTag.
+ if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Update the status.
+ if err := testStructs.Processor.Workers().ProcessFromClientAPI(
+ ctx,
+ &messages.FromClientAPI{
+ APObjectType: ap.ObjectNote,
+ APActivityType: ap.ActivityUpdate,
+ GTSModel: status,
+ Origin: postingAccount,
+ },
+ ); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Check status in home stream.
+ suite.checkStreamed(
+ homeStream,
+ true,
+ "",
+ stream.EventTypeStatusUpdate,
+ )
+}
+
func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
testStructs := suite.SetupTestStructs()
defer suite.TearDownTestStructs(testStructs)
diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go
index 7bd0a51c6..c0987effd 100644
--- a/internal/processing/workers/surfacetimeline.go
+++ b/internal/processing/workers/surfacetimeline.go
@@ -30,10 +30,12 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// timelineAndNotifyStatus inserts the given status into the HOME
-// and LIST timelines of accounts that follow the status author.
+// and LIST timelines of accounts that follow the status author,
+// as well as the HOME timelines of accounts that follow tags used by the status.
//
// It will also handle notifications for any mentions attached to
// the account, notifications for any local accounts that want
@@ -56,18 +58,24 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.
follows = append(follows, >smodel.Follow{
AccountID: status.AccountID,
Account: status.Account,
- Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself.
- ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs.
+ Notify: util.Ptr(false), // Account shouldn't notify itself.
+ ShowReblogs: util.Ptr(true), // Account should show own reblogs.
})
}
// Timeline the status for each local follower of this account.
// This will also handle notifying any followers with notify
// set to true on their follow.
- if err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil {
+ homeTimelinedAccountIDs, err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows)
+ if err != nil {
return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err)
}
+ // Timeline the status for each local account who follows a tag used by this status.
+ if err := s.timelineAndNotifyStatusForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil {
+ return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err)
+ }
+
// Notify each local account that's mentioned by this status.
if err := s.notifyMentions(ctx, status); err != nil {
return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err)
@@ -90,15 +98,18 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.
// adding the status to list timelines + home timelines of each
// follower, as appropriate, and notifying each follower of the
// new status, if the status is eligible for notification.
+//
+// Returns a list of accounts which had this status inserted into their home timelines.
func (s *Surface) timelineAndNotifyStatusForFollowers(
ctx context.Context,
status *gtsmodel.Status,
follows []*gtsmodel.Follow,
-) error {
+) ([]string, error) {
var (
- errs gtserror.MultiError
- boost = status.BoostOfID != ""
- reply = status.InReplyToURI != ""
+ errs gtserror.MultiError
+ boost = status.BoostOfID != ""
+ reply = status.InReplyToURI != ""
+ homeTimelinedAccountIDs = []string{}
)
for _, follow := range follows {
@@ -122,17 +133,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
continue
}
- filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
+ filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID)
if err != nil {
- return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
+ errs.Append(err)
+ continue
}
- mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil)
- if err != nil {
- return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err)
- }
- compiledMutes := usermute.NewCompiledUserMuteList(mutes)
-
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusForFollow(
@@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
follow,
&errs,
filters,
- compiledMutes,
+ mutes,
)
// Add status to home timeline for owner
@@ -154,7 +160,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
status,
stream.TimelineHome,
filters,
- compiledMutes,
+ mutes,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@@ -166,6 +172,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// timeline, we shouldn't notify it.
continue
}
+ homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
if !*follow.Notify {
// This follower doesn't have notifs
@@ -196,7 +203,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
}
}
- return errs.Combine()
+ return homeTimelinedAccountIDs, errs.Combine()
}
// listTimelineStatusForFollow puts the given status
@@ -259,6 +266,22 @@ func (s *Surface) listTimelineStatusForFollow(
}
}
+// getFiltersAndMutes returns an account's filters and mutes.
+func (s *Surface) getFiltersAndMutes(ctx context.Context, accountID string) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, error) {
+ filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID)
+ if err != nil {
+ return nil, nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err)
+ }
+
+ mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), accountID, nil)
+ if err != nil {
+ return nil, nil, gtserror.Newf("couldn't retrieve mutes for account %s: %w", accountID, err)
+ }
+ compiledMutes := usermute.NewCompiledUserMuteList(mutes)
+
+ return filters, compiledMutes, err
+}
+
// listEligible checks if the given status is eligible
// for inclusion in the list that that the given listEntry
// belongs to, based on the replies policy of the list.
@@ -391,6 +414,138 @@ func (s *Surface) timelineStatus(
return true, nil
}
+// timelineAndNotifyStatusForTagFollowers inserts the status into the
+// home timeline of each local account which follows a useable tag from the status,
+// skipping accounts for which it would have already been inserted.
+func (s *Surface) timelineAndNotifyStatusForTagFollowers(
+ ctx context.Context,
+ status *gtsmodel.Status,
+ alreadyHomeTimelinedAccountIDs []string,
+) error {
+ tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs)
+ if err != nil {
+ return err
+ }
+
+ if status.BoostOf != nil {
+ // Unwrap boost and work with the original status.
+ status = status.BoostOf
+ }
+
+ // Insert the status into the home timeline of each tag follower.
+ errs := gtserror.MultiError{}
+ for _, tagFollowerAccount := range tagFollowerAccounts {
+ filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID)
+ if err != nil {
+ errs.Append(err)
+ continue
+ }
+
+ if _, err := s.timelineStatus(
+ ctx,
+ s.State.Timelines.Home.IngestOne,
+ tagFollowerAccount.ID, // home timelines are keyed by account ID
+ tagFollowerAccount,
+ status,
+ stream.TimelineHome,
+ filters,
+ mutes,
+ ); err != nil {
+ errs.Appendf(
+ "error inserting status %s into home timeline for account %s: %w",
+ status.ID,
+ tagFollowerAccount.ID,
+ err,
+ )
+ }
+ }
+ return errs.Combine()
+}
+
+// tagFollowersForStatus gets local accounts which follow any useable tags from the status,
+// skipping any with IDs in the provided list, and any that shouldn't be able to see it due to blocks.
+func (s *Surface) tagFollowersForStatus(
+ ctx context.Context,
+ status *gtsmodel.Status,
+ skipAccountIDs []string,
+) ([]*gtsmodel.Account, error) {
+ // If the status is a boost, look at the tags from the boosted status.
+ taggedStatus := status
+ if status.BoostOf != nil {
+ taggedStatus = status.BoostOf
+ }
+
+ if taggedStatus.Visibility != gtsmodel.VisibilityPublic || len(taggedStatus.Tags) == 0 {
+ // Only public statuses with tags are eligible for tag processing.
+ return nil, nil
+ }
+
+ // Build list of useable tag IDs.
+ useableTagIDs := make([]string, 0, len(taggedStatus.Tags))
+ for _, tag := range taggedStatus.Tags {
+ if *tag.Useable {
+ useableTagIDs = append(useableTagIDs, tag.ID)
+ }
+ }
+ if len(useableTagIDs) == 0 {
+ return nil, nil
+ }
+
+ // Get IDs for all accounts who follow one or more of the useable tags from this status.
+ allTagFollowerAccountIDs, err := s.State.DB.GetAccountIDsFollowingTagIDs(ctx, useableTagIDs)
+ if err != nil {
+ return nil, gtserror.Newf("DB error getting followers for tags of status %s: %w", taggedStatus.ID, err)
+ }
+ if len(allTagFollowerAccountIDs) == 0 {
+ return nil, nil
+ }
+
+ // Build set for faster lookup of account IDs to skip.
+ skipAccountIDSet := make(map[string]struct{}, len(skipAccountIDs))
+ for _, accountID := range skipAccountIDs {
+ skipAccountIDSet[accountID] = struct{}{}
+ }
+
+ // Build list of tag follower account IDs,
+ // except those which have already had this status inserted into their timeline.
+ tagFollowerAccountIDs := make([]string, 0, len(allTagFollowerAccountIDs))
+ for _, accountID := range allTagFollowerAccountIDs {
+ if _, skip := skipAccountIDSet[accountID]; skip {
+ continue
+ }
+ tagFollowerAccountIDs = append(tagFollowerAccountIDs, accountID)
+ }
+ if len(tagFollowerAccountIDs) == 0 {
+ return nil, nil
+ }
+
+ // Retrieve accounts for remaining tag followers.
+ tagFollowerAccounts, err := s.State.DB.GetAccountsByIDs(ctx, tagFollowerAccountIDs)
+ if err != nil {
+ return nil, gtserror.Newf("DB error getting accounts for followers of tags of status %s: %w", taggedStatus.ID, err)
+ }
+
+ // Check the visibility of the *input* status for each account.
+ // This accounts for the visibility of the boost as well as the original, if the input status is a boost.
+ errs := gtserror.MultiError{}
+ visibleTagFollowerAccounts := make([]*gtsmodel.Account, 0, len(tagFollowerAccounts))
+ for _, account := range tagFollowerAccounts {
+ visible, err := s.VisFilter.StatusVisible(ctx, account, status)
+ if err != nil {
+ errs.Appendf(
+ "error checking visibility of status %s to account %s",
+ status.ID,
+ account.ID,
+ )
+ }
+ if visible {
+ visibleTagFollowerAccounts = append(visibleTagFollowerAccounts, account)
+ }
+ }
+
+ return visibleTagFollowerAccounts, errs.Combine()
+}
+
// deleteStatusFromTimelines completely removes the given status from all timelines.
// It will also stream deletion of the status to all open streams.
func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error {
@@ -425,7 +580,7 @@ func (s *Surface) invalidateStatusFromTimelines(ctx context.Context, statusID st
}
// timelineStatusUpdate looks up HOME and LIST timelines of accounts
-// that follow the the status author and pushes edit messages into any
+// that follow the the status author or tags and pushes edit messages into any
// active streams.
// Note that calling invalidateStatusFromTimelines takes care of the
// state in general, we just need to do this for any streams that are
@@ -454,10 +609,15 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta
}
// Push to streams for each local follower of this account.
- if err := s.timelineStatusUpdateForFollowers(ctx, status, follows); err != nil {
+ homeTimelinedAccountIDs, err := s.timelineStatusUpdateForFollowers(ctx, status, follows)
+ if err != nil {
return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err)
}
+ if err := s.timelineStatusUpdateForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil {
+ return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err)
+ }
+
return nil
}
@@ -465,13 +625,16 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta
// slice of followers of the account that posted the given status,
// pushing update messages into open list/home streams of each
// follower.
+//
+// Returns a list of accounts which had this status updated in their home timelines.
func (s *Surface) timelineStatusUpdateForFollowers(
ctx context.Context,
status *gtsmodel.Status,
follows []*gtsmodel.Follow,
-) error {
+) ([]string, error) {
var (
- errs gtserror.MultiError
+ errs gtserror.MultiError
+ homeTimelinedAccountIDs = []string{}
)
for _, follow := range follows {
@@ -495,17 +658,12 @@ func (s *Surface) timelineStatusUpdateForFollowers(
continue
}
- filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
+ filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID)
if err != nil {
- return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
+ errs.Append(err)
+ continue
}
- mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil)
- if err != nil {
- return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err)
- }
- compiledMutes := usermute.NewCompiledUserMuteList(mutes)
-
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusUpdateForFollow(
@@ -514,26 +672,30 @@ func (s *Surface) timelineStatusUpdateForFollowers(
follow,
&errs,
filters,
- compiledMutes,
+ mutes,
)
// Add status to home timeline for owner
// of this follow, if applicable.
- err = s.timelineStreamStatusUpdate(
+ homeTimelined, err := s.timelineStreamStatusUpdate(
ctx,
follow.Account,
status,
stream.TimelineHome,
filters,
- compiledMutes,
+ mutes,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
continue
}
+
+ if homeTimelined {
+ homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
+ }
}
- return errs.Combine()
+ return homeTimelinedAccountIDs, errs.Combine()
}
// listTimelineStatusUpdateForFollow pushes edits of the given status
@@ -580,7 +742,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
// At this point we are certain this status
// should be included in the timeline of the
// list that this list entry belongs to.
- if err := s.timelineStreamStatusUpdate(
+ if _, err := s.timelineStreamStatusUpdate(
ctx,
follow.Account,
status,
@@ -596,6 +758,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
// timelineStatusUpdate streams the edited status to the user using the
// given streamType.
+//
+// Returns whether it was actually streamed.
func (s *Surface) timelineStreamStatusUpdate(
ctx context.Context,
account *gtsmodel.Account,
@@ -603,16 +767,62 @@ func (s *Surface) timelineStreamStatusUpdate(
streamType string,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
-) error {
+) (bool, error) {
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
// Don't put this status in the stream.
- return nil
+ return false, nil
}
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
- return err
+ return false, err
}
s.Stream.StatusUpdate(ctx, account, apiStatus, streamType)
- return nil
+ return true, nil
+}
+
+// timelineStatusUpdateForTagFollowers streams update notifications to the
+// home timeline of each local account which follows a tag used by the status,
+// skipping accounts for which it would have already been streamed.
+func (s *Surface) timelineStatusUpdateForTagFollowers(
+ ctx context.Context,
+ status *gtsmodel.Status,
+ alreadyHomeTimelinedAccountIDs []string,
+) error {
+ tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs)
+ if err != nil {
+ return err
+ }
+
+ if status.BoostOf != nil {
+ // Unwrap boost and work with the original status.
+ status = status.BoostOf
+ }
+
+ // Stream the update to the home timeline of each tag follower.
+ errs := gtserror.MultiError{}
+ for _, tagFollowerAccount := range tagFollowerAccounts {
+ filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID)
+ if err != nil {
+ errs.Append(err)
+ continue
+ }
+
+ if _, err := s.timelineStreamStatusUpdate(
+ ctx,
+ tagFollowerAccount,
+ status,
+ stream.TimelineHome,
+ filters,
+ mutes,
+ ); err != nil {
+ errs.Appendf(
+ "error updating status %s on home timeline for account %s: %w",
+ status.ID,
+ tagFollowerAccount.ID,
+ err,
+ )
+ }
+ }
+ return errs.Combine()
}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index cbe746d2f..c555e1d04 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -740,7 +740,8 @@ func (c *Converter) EmojiCategoryToAPIEmojiCategory(ctx context.Context, categor
// TagToAPITag converts a gts model tag into its api (frontend) representation for serialization on the API.
// If stubHistory is set to 'true', then the 'history' field of the tag will be populated with a pointer to an empty slice, for API compatibility reasons.
-func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool) (apimodel.Tag, error) {
+// following is an optional flag marking whether the currently authenticated user (if there is one) is following the tag.
+func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistory bool, following *bool) (apimodel.Tag, error) {
return apimodel.Tag{
Name: strings.ToLower(t.Name),
URL: uris.URIForTag(t.Name),
@@ -752,6 +753,7 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
h := make([]any, 0)
return &h
}(),
+ Following: following,
}, nil
}
@@ -2347,7 +2349,7 @@ func (c *Converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
// Convert GTS models to frontend models
for _, tag := range tags {
- apiTag, err := c.TagToAPITag(ctx, tag, false)
+ apiTag, err := c.TagToAPITag(ctx, tag, false, nil)
if err != nil {
errs.Appendf("error converting tag %s to api tag: %w", tag.ID, err)
continue
diff --git a/test/envparsing.sh b/test/envparsing.sh
index 83dfb85fc..281bf7405 100755
--- a/test/envparsing.sh
+++ b/test/envparsing.sh
@@ -23,6 +23,7 @@ EXPECT=$(cat << "EOF"
"application-name": "gts",
"bind-address": "127.0.0.1",
"cache": {
+ "account-ids-following-tag-mem-ratio": 1,
"account-mem-ratio": 5,
"account-note-mem-ratio": 1,
"account-settings-mem-ratio": 0.1,
@@ -63,6 +64,7 @@ EXPECT=$(cat << "EOF"
"status-fave-ids-mem-ratio": 3,
"status-fave-mem-ratio": 2,
"status-mem-ratio": 5,
+ "tag-ids-followed-by-account-mem-ratio": 1,
"tag-mem-ratio": 2,
"thread-mute-mem-ratio": 0.2,
"token-mem-ratio": 0.75,