mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-21 16:55:38 +03:00
[feature] Implement following hashtags (#3141)
* Implement followed tags API * Insert statuses with followed tags into home timelines * Test following and unfollowing tags * Correct Swagger path params * Trim conversation caches * Migration for followed_tags table * Followed tag caches and DB implementation * Lint and tests * Add missing tag info endpoint, reorganize tag API * Unwrap boosts when timelining based on tags * Apply visibility filters to tag followers * Address review comments
This commit is contained in:
parent
368c97f0f8
commit
a237e2b295
37 changed files with 2820 additions and 46 deletions
|
@ -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: |-
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ import (
|
|||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - featured_tags
|
||||
// - tags
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
|
|
43
internal/api/client/followedtags/followedtags.go
Normal file
43
internal/api/client/followedtags/followedtags.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
104
internal/api/client/followedtags/followedtags_test.go
Normal file
104
internal/api/client/followedtags/followedtags_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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))
|
||||
}
|
139
internal/api/client/followedtags/get.go
Normal file
139
internal/api/client/followedtags/get.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
125
internal/api/client/followedtags/get_test.go
Normal file
125
internal/api/client/followedtags/get_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
92
internal/api/client/tags/follow.go
Normal file
92
internal/api/client/tags/follow.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
82
internal/api/client/tags/follow_test.go
Normal file
82
internal/api/client/tags/follow_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
89
internal/api/client/tags/get.go
Normal file
89
internal/api/client/tags/get.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
93
internal/api/client/tags/get_test.go
Normal file
93
internal/api/client/tags/get_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
49
internal/api/client/tags/tags.go
Normal file
49
internal/api/client/tags/tags.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
179
internal/api/client/tags/tags_test.go
Normal file
179
internal/api/client/tags/tags_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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))
|
||||
}
|
94
internal/api/client/tags/unfollow.go
Normal file
94
internal/api/client/tags/unfollow.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
82
internal/api/client/tags/unfollow_test.go
Normal file
82
internal/api/client/tags/unfollow_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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"`
|
||||
}
|
||||
|
|
6
internal/cache/cache.go
vendored
6
internal/cache/cache.go
vendored
|
@ -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)
|
||||
|
|
28
internal/cache/db.go
vendored
28
internal/cache/db.go
vendored
|
@ -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.
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
67
internal/processing/tags/follow.go
Normal file
67
internal/processing/tags/follow.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
73
internal/processing/tags/followed.go
Normal file
73
internal/processing/tags/followed.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
53
internal/processing/tags/followedtags.go
Normal file
53
internal/processing/tags/followedtags.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
57
internal/processing/tags/get.go
Normal file
57
internal/processing/tags/get.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
58
internal/processing/tags/unfollow.go
Normal file
58
internal/processing/tags/unfollow.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue