[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
|
@ -2916,6 +2916,12 @@ definitions:
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users
|
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users
|
||||||
tag:
|
tag:
|
||||||
properties:
|
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:
|
history:
|
||||||
description: |-
|
description: |-
|
||||||
History of this hashtag's usage.
|
History of this hashtag's usage.
|
||||||
|
@ -6439,7 +6445,7 @@ paths:
|
||||||
- read:accounts
|
- read:accounts
|
||||||
summary: Get an array of all hashtags that you currently have featured on your profile.
|
summary: Get an array of all hashtags that you currently have featured on your profile.
|
||||||
tags:
|
tags:
|
||||||
- featured_tags
|
- tags
|
||||||
/api/v1/filters:
|
/api/v1/filters:
|
||||||
get:
|
get:
|
||||||
operationId: filtersV1Get
|
operationId: filtersV1Get
|
||||||
|
@ -6834,6 +6840,58 @@ paths:
|
||||||
summary: Reject/deny follow request from the given account ID.
|
summary: Reject/deny follow request from the given account ID.
|
||||||
tags:
|
tags:
|
||||||
- follow_requests
|
- 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:
|
/api/v1/instance:
|
||||||
get:
|
get:
|
||||||
operationId: instanceGetV1
|
operationId: instanceGetV1
|
||||||
|
@ -9072,6 +9130,103 @@ paths:
|
||||||
summary: Initiate a websocket connection for live streaming of statuses and notifications.
|
summary: Initiate a websocket connection for live streaming of statuses and notifications.
|
||||||
tags:
|
tags:
|
||||||
- streaming
|
- 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:
|
/api/v1/timelines/home:
|
||||||
get:
|
get:
|
||||||
description: |-
|
description: |-
|
||||||
|
|
|
@ -32,6 +32,7 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/featuredtags"
|
||||||
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
|
filtersV1 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v1"
|
||||||
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
|
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/followrequests"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
|
"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/search"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/streaming"
|
"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/timelines"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
@ -59,7 +61,7 @@ type Client struct {
|
||||||
processor *processing.Processor
|
processor *processing.Processor
|
||||||
db db.DB
|
db db.DB
|
||||||
|
|
||||||
accounts *accounts.Module // api/v1/accounts
|
accounts *accounts.Module // api/v1/accounts, api/v1/profile
|
||||||
admin *admin.Module // api/v1/admin
|
admin *admin.Module // api/v1/admin
|
||||||
apps *apps.Module // api/v1/apps
|
apps *apps.Module // api/v1/apps
|
||||||
blocks *blocks.Module // api/v1/blocks
|
blocks *blocks.Module // api/v1/blocks
|
||||||
|
@ -71,6 +73,7 @@ type Client struct {
|
||||||
filtersV1 *filtersV1.Module // api/v1/filters
|
filtersV1 *filtersV1.Module // api/v1/filters
|
||||||
filtersV2 *filtersV2.Module // api/v2/filters
|
filtersV2 *filtersV2.Module // api/v2/filters
|
||||||
followRequests *followrequests.Module // api/v1/follow_requests
|
followRequests *followrequests.Module // api/v1/follow_requests
|
||||||
|
followedTags *followedtags.Module // api/v1/followed_tags
|
||||||
instance *instance.Module // api/v1/instance
|
instance *instance.Module // api/v1/instance
|
||||||
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
|
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
|
||||||
lists *lists.Module // api/v1/lists
|
lists *lists.Module // api/v1/lists
|
||||||
|
@ -84,6 +87,7 @@ type Client struct {
|
||||||
search *search.Module // api/v1/search, api/v2/search
|
search *search.Module // api/v1/search, api/v2/search
|
||||||
statuses *statuses.Module // api/v1/statuses
|
statuses *statuses.Module // api/v1/statuses
|
||||||
streaming *streaming.Module // api/v1/streaming
|
streaming *streaming.Module // api/v1/streaming
|
||||||
|
tags *tags.Module // api/v1/tags
|
||||||
timelines *timelines.Module // api/v1/timelines
|
timelines *timelines.Module // api/v1/timelines
|
||||||
user *user.Module // api/v1/user
|
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.filtersV1.Route(h)
|
||||||
c.filtersV2.Route(h)
|
c.filtersV2.Route(h)
|
||||||
c.followRequests.Route(h)
|
c.followRequests.Route(h)
|
||||||
|
c.followedTags.Route(h)
|
||||||
c.instance.Route(h)
|
c.instance.Route(h)
|
||||||
c.interactionPolicies.Route(h)
|
c.interactionPolicies.Route(h)
|
||||||
c.lists.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.search.Route(h)
|
||||||
c.statuses.Route(h)
|
c.statuses.Route(h)
|
||||||
c.streaming.Route(h)
|
c.streaming.Route(h)
|
||||||
|
c.tags.Route(h)
|
||||||
c.timelines.Route(h)
|
c.timelines.Route(h)
|
||||||
c.user.Route(h)
|
c.user.Route(h)
|
||||||
}
|
}
|
||||||
|
@ -151,6 +157,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||||
filtersV1: filtersV1.New(p),
|
filtersV1: filtersV1.New(p),
|
||||||
filtersV2: filtersV2.New(p),
|
filtersV2: filtersV2.New(p),
|
||||||
followRequests: followrequests.New(p),
|
followRequests: followrequests.New(p),
|
||||||
|
followedTags: followedtags.New(p),
|
||||||
instance: instance.New(p),
|
instance: instance.New(p),
|
||||||
interactionPolicies: interactionpolicies.New(p),
|
interactionPolicies: interactionpolicies.New(p),
|
||||||
lists: lists.New(p),
|
lists: lists.New(p),
|
||||||
|
@ -164,6 +171,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||||
search: search.New(p),
|
search: search.New(p),
|
||||||
statuses: statuses.New(p),
|
statuses: statuses.New(p),
|
||||||
streaming: streaming.New(p, time.Second*30, 4096),
|
streaming: streaming.New(p, time.Second*30, 4096),
|
||||||
|
tags: tags.New(p),
|
||||||
timelines: timelines.New(p),
|
timelines: timelines.New(p),
|
||||||
user: user.New(p),
|
user: user.New(p),
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ import (
|
||||||
//
|
//
|
||||||
// ---
|
// ---
|
||||||
// tags:
|
// tags:
|
||||||
// - featured_tags
|
// - tags
|
||||||
//
|
//
|
||||||
// produces:
|
// produces:
|
||||||
// - application/json
|
// - application/json
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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.
|
// Currently just a stub, if provided will always be an empty array.
|
||||||
// example: []
|
// example: []
|
||||||
History *[]any `json:"history,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,6 +57,7 @@ func (c *Caches) Init() {
|
||||||
log.Infof(nil, "init: %p", c)
|
log.Infof(nil, "init: %p", c)
|
||||||
|
|
||||||
c.initAccount()
|
c.initAccount()
|
||||||
|
c.initAccountIDsFollowingTag()
|
||||||
c.initAccountNote()
|
c.initAccountNote()
|
||||||
c.initAccountSettings()
|
c.initAccountSettings()
|
||||||
c.initAccountStats()
|
c.initAccountStats()
|
||||||
|
@ -98,6 +99,7 @@ func (c *Caches) Init() {
|
||||||
c.initStatusFave()
|
c.initStatusFave()
|
||||||
c.initStatusFaveIDs()
|
c.initStatusFaveIDs()
|
||||||
c.initTag()
|
c.initTag()
|
||||||
|
c.initTagIDsFollowedByAccount()
|
||||||
c.initThreadMute()
|
c.initThreadMute()
|
||||||
c.initToken()
|
c.initToken()
|
||||||
c.initTombstone()
|
c.initTombstone()
|
||||||
|
@ -134,6 +136,7 @@ func (c *Caches) Stop() {
|
||||||
// significant overhead to all cache writes.
|
// significant overhead to all cache writes.
|
||||||
func (c *Caches) Sweep(threshold float64) {
|
func (c *Caches) Sweep(threshold float64) {
|
||||||
c.DB.Account.Trim(threshold)
|
c.DB.Account.Trim(threshold)
|
||||||
|
c.DB.AccountIDsFollowingTag.Trim(threshold)
|
||||||
c.DB.AccountNote.Trim(threshold)
|
c.DB.AccountNote.Trim(threshold)
|
||||||
c.DB.AccountSettings.Trim(threshold)
|
c.DB.AccountSettings.Trim(threshold)
|
||||||
c.DB.AccountStats.Trim(threshold)
|
c.DB.AccountStats.Trim(threshold)
|
||||||
|
@ -142,6 +145,8 @@ func (c *Caches) Sweep(threshold float64) {
|
||||||
c.DB.BlockIDs.Trim(threshold)
|
c.DB.BlockIDs.Trim(threshold)
|
||||||
c.DB.BoostOfIDs.Trim(threshold)
|
c.DB.BoostOfIDs.Trim(threshold)
|
||||||
c.DB.Client.Trim(threshold)
|
c.DB.Client.Trim(threshold)
|
||||||
|
c.DB.Conversation.Trim(threshold)
|
||||||
|
c.DB.ConversationLastStatusIDs.Trim(threshold)
|
||||||
c.DB.Emoji.Trim(threshold)
|
c.DB.Emoji.Trim(threshold)
|
||||||
c.DB.EmojiCategory.Trim(threshold)
|
c.DB.EmojiCategory.Trim(threshold)
|
||||||
c.DB.Filter.Trim(threshold)
|
c.DB.Filter.Trim(threshold)
|
||||||
|
@ -171,6 +176,7 @@ func (c *Caches) Sweep(threshold float64) {
|
||||||
c.DB.StatusFave.Trim(threshold)
|
c.DB.StatusFave.Trim(threshold)
|
||||||
c.DB.StatusFaveIDs.Trim(threshold)
|
c.DB.StatusFaveIDs.Trim(threshold)
|
||||||
c.DB.Tag.Trim(threshold)
|
c.DB.Tag.Trim(threshold)
|
||||||
|
c.DB.TagIDsFollowedByAccount.Trim(threshold)
|
||||||
c.DB.ThreadMute.Trim(threshold)
|
c.DB.ThreadMute.Trim(threshold)
|
||||||
c.DB.Token.Trim(threshold)
|
c.DB.Token.Trim(threshold)
|
||||||
c.DB.Tombstone.Trim(threshold)
|
c.DB.Tombstone.Trim(threshold)
|
||||||
|
|
|
@ -29,6 +29,9 @@ type DBCaches struct {
|
||||||
// Account provides access to the gtsmodel Account database cache.
|
// Account provides access to the gtsmodel Account database cache.
|
||||||
Account StructCache[*gtsmodel.Account]
|
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 provides access to the gtsmodel Note database cache.
|
||||||
AccountNote StructCache[*gtsmodel.AccountNote]
|
AccountNote StructCache[*gtsmodel.AccountNote]
|
||||||
|
|
||||||
|
@ -160,6 +163,9 @@ type DBCaches struct {
|
||||||
// Tag provides access to the gtsmodel Tag database cache.
|
// Tag provides access to the gtsmodel Tag database cache.
|
||||||
Tag StructCache[*gtsmodel.Tag]
|
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 provides access to the gtsmodel ThreadMute database cache.
|
||||||
ThreadMute StructCache[*gtsmodel.ThreadMute]
|
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() {
|
func (c *Caches) initAccountNote() {
|
||||||
// Calculate maximum cache size.
|
// Calculate maximum cache size.
|
||||||
cap := calculateResultCacheMax(
|
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() {
|
func (c *Caches) initThreadMute() {
|
||||||
cap := calculateResultCacheMax(
|
cap := calculateResultCacheMax(
|
||||||
sizeofThreadMute(), // model in-mem size.
|
sizeofThreadMute(), // model in-mem size.
|
||||||
|
|
|
@ -193,6 +193,7 @@ type HTTPClientConfiguration struct {
|
||||||
type CacheConfiguration struct {
|
type CacheConfiguration struct {
|
||||||
MemoryTarget bytesize.Size `name:"memory-target"`
|
MemoryTarget bytesize.Size `name:"memory-target"`
|
||||||
AccountMemRatio float64 `name:"account-mem-ratio"`
|
AccountMemRatio float64 `name:"account-mem-ratio"`
|
||||||
|
AccountIDsFollowingTagMemRatio float64 `name:"account-ids-following-tag-mem-ratio"`
|
||||||
AccountNoteMemRatio float64 `name:"account-note-mem-ratio"`
|
AccountNoteMemRatio float64 `name:"account-note-mem-ratio"`
|
||||||
AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"`
|
AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"`
|
||||||
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
|
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
|
||||||
|
@ -232,6 +233,7 @@ type CacheConfiguration struct {
|
||||||
StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"`
|
StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"`
|
||||||
StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"`
|
StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"`
|
||||||
TagMemRatio float64 `name:"tag-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"`
|
ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"`
|
||||||
TokenMemRatio float64 `name:"token-mem-ratio"`
|
TokenMemRatio float64 `name:"token-mem-ratio"`
|
||||||
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
|
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
|
||||||
|
|
|
@ -157,6 +157,7 @@ var Defaults = Configuration{
|
||||||
// file have been addressed, these should
|
// file have been addressed, these should
|
||||||
// be able to make some more sense :D
|
// be able to make some more sense :D
|
||||||
AccountMemRatio: 5,
|
AccountMemRatio: 5,
|
||||||
|
AccountIDsFollowingTagMemRatio: 1,
|
||||||
AccountNoteMemRatio: 1,
|
AccountNoteMemRatio: 1,
|
||||||
AccountSettingsMemRatio: 0.1,
|
AccountSettingsMemRatio: 0.1,
|
||||||
AccountStatsMemRatio: 2,
|
AccountStatsMemRatio: 2,
|
||||||
|
@ -196,6 +197,7 @@ var Defaults = Configuration{
|
||||||
StatusFaveMemRatio: 2,
|
StatusFaveMemRatio: 2,
|
||||||
StatusFaveIDsMemRatio: 3,
|
StatusFaveIDsMemRatio: 3,
|
||||||
TagMemRatio: 2,
|
TagMemRatio: 2,
|
||||||
|
TagIDsFollowedByAccountMemRatio: 1,
|
||||||
ThreadMuteMemRatio: 0.2,
|
ThreadMuteMemRatio: 0.2,
|
||||||
TokenMemRatio: 0.75,
|
TokenMemRatio: 0.75,
|
||||||
TombstoneMemRatio: 0.5,
|
TombstoneMemRatio: 0.5,
|
||||||
|
|
|
@ -2775,6 +2775,37 @@ func GetCacheAccountMemRatio() float64 { return global.GetCacheAccountMemRatio()
|
||||||
// SetCacheAccountMemRatio safely sets the value for global configuration 'Cache.AccountMemRatio' field
|
// SetCacheAccountMemRatio safely sets the value for global configuration 'Cache.AccountMemRatio' field
|
||||||
func SetCacheAccountMemRatio(v float64) { global.SetCacheAccountMemRatio(v) }
|
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
|
// GetCacheAccountNoteMemRatio safely fetches the Configuration value for state's 'Cache.AccountNoteMemRatio' field
|
||||||
func (st *ConfigState) GetCacheAccountNoteMemRatio() (v float64) {
|
func (st *ConfigState) GetCacheAccountNoteMemRatio() (v float64) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
@ -3758,6 +3789,37 @@ func GetCacheTagMemRatio() float64 { return global.GetCacheTagMemRatio() }
|
||||||
// SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field
|
// SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field
|
||||||
func SetCacheTagMemRatio(v float64) { global.SetCacheTagMemRatio(v) }
|
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
|
// GetCacheThreadMuteMemRatio safely fetches the Configuration value for state's 'Cache.ThreadMuteMemRatio' field
|
||||||
func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) {
|
func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) {
|
||||||
st.mutex.RLock()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
@ -131,3 +135,158 @@ func (t *tagDB) PutTag(ctx context.Context, tag *gtsmodel.Tag) error {
|
||||||
|
|
||||||
return nil
|
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"
|
"context"
|
||||||
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tag contains functions for getting/creating tags in the database.
|
// Tag contains functions for getting/creating tags in the database.
|
||||||
|
@ -36,4 +37,24 @@ type Tag interface {
|
||||||
|
|
||||||
// GetTags gets multiple tags.
|
// GetTags gets multiple tags.
|
||||||
GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, error)
|
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.
|
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.
|
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)
|
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.
|
// Delete account stats model.
|
||||||
if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil {
|
if err := p.state.DB.DeleteAccountStats(ctx, account.ID); err != nil {
|
||||||
return gtserror.Newf("error deleting stats for account: %w", err)
|
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/search"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/stream"
|
"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/timeline"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing/workers"
|
"github.com/superseriousbusiness/gotosocial/internal/processing/workers"
|
||||||
|
@ -88,6 +89,7 @@ type Processor struct {
|
||||||
search search.Processor
|
search search.Processor
|
||||||
status status.Processor
|
status status.Processor
|
||||||
stream stream.Processor
|
stream stream.Processor
|
||||||
|
tags tags.Processor
|
||||||
timeline timeline.Processor
|
timeline timeline.Processor
|
||||||
user user.Processor
|
user user.Processor
|
||||||
workers workers.Processor
|
workers workers.Processor
|
||||||
|
@ -153,6 +155,10 @@ func (p *Processor) Stream() *stream.Processor {
|
||||||
return &p.stream
|
return &p.stream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Processor) Tags() *tags.Processor {
|
||||||
|
return &p.tags
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Processor) Timeline() *timeline.Processor {
|
func (p *Processor) Timeline() *timeline.Processor {
|
||||||
return &p.timeline
|
return &p.timeline
|
||||||
}
|
}
|
||||||
|
@ -207,6 +213,7 @@ func NewProcessor(
|
||||||
processor.markers = markers.New(state, converter)
|
processor.markers = markers.New(state, converter)
|
||||||
processor.polls = polls.New(&common, state, converter)
|
processor.polls = polls.New(&common, state, converter)
|
||||||
processor.report = report.New(state, converter)
|
processor.report = report.New(state, converter)
|
||||||
|
processor.tags = tags.New(state, converter)
|
||||||
processor.timeline = timeline.New(state, converter, visFilter)
|
processor.timeline = timeline.New(state, converter, visFilter)
|
||||||
processor.search = search.New(state, federator, converter, visFilter)
|
processor.search = search.New(state, federator, converter, visFilter)
|
||||||
processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc)
|
processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc)
|
||||||
|
|
|
@ -146,7 +146,7 @@ func (p *Processor) packageHashtags(
|
||||||
} else {
|
} else {
|
||||||
// If API not version 1, provide slice of full tags.
|
// If API not version 1, provide slice of full tags.
|
||||||
rangeF = func(tag *gtsmodel.Tag) {
|
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 {
|
if err != nil {
|
||||||
log.Debugf(
|
log.Debugf(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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,
|
boostOfStatus *gtsmodel.Status,
|
||||||
mentionedAccounts []*gtsmodel.Account,
|
mentionedAccounts []*gtsmodel.Account,
|
||||||
createThread bool,
|
createThread bool,
|
||||||
|
tagIDs []string,
|
||||||
) *gtsmodel.Status {
|
) *gtsmodel.Status {
|
||||||
var (
|
var (
|
||||||
protocol = config.GetProtocol()
|
protocol = config.GetProtocol()
|
||||||
|
@ -65,6 +66,7 @@ func (suite *FromClientAPITestSuite) newStatus(
|
||||||
URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID,
|
URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID,
|
||||||
URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID,
|
URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID,
|
||||||
Content: "pee pee poo poo",
|
Content: "pee pee poo poo",
|
||||||
|
TagIDs: tagIDs,
|
||||||
Local: util.Ptr(true),
|
Local: util.Ptr(true),
|
||||||
AccountURI: account.URI,
|
AccountURI: account.URI,
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
|
@ -256,6 +258,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -367,6 +370,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() {
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -428,6 +432,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() {
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
threadMute = >smodel.ThreadMute{
|
threadMute = >smodel.ThreadMute{
|
||||||
ID: "01HD3KRMBB1M85QRWHD912QWRE",
|
ID: "01HD3KRMBB1M85QRWHD912QWRE",
|
||||||
|
@ -488,6 +493,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() {
|
||||||
suite.testStatuses["local_account_1_status_1"],
|
suite.testStatuses["local_account_1_status_1"],
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
threadMute = >smodel.ThreadMute{
|
threadMute = >smodel.ThreadMute{
|
||||||
ID: "01HD3KRMBB1M85QRWHD912QWRE",
|
ID: "01HD3KRMBB1M85QRWHD912QWRE",
|
||||||
|
@ -553,6 +559,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -628,6 +635,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -708,6 +716,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -780,6 +789,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() {
|
||||||
suite.testStatuses["local_account_2_status_1"],
|
suite.testStatuses["local_account_2_status_1"],
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -843,6 +853,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() {
|
||||||
suite.testStatuses["local_account_2_status_1"],
|
suite.testStatuses["local_account_2_status_1"],
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -912,6 +923,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat
|
||||||
nil,
|
nil,
|
||||||
[]*gtsmodel.Account{receivingAccount},
|
[]*gtsmodel.Account{receivingAccount},
|
||||||
true,
|
true,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -997,6 +1009,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate
|
||||||
nil,
|
nil,
|
||||||
[]*gtsmodel.Account{receivingAccount},
|
[]*gtsmodel.Account{receivingAccount},
|
||||||
true,
|
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() {
|
func (suite *FromClientAPITestSuite) TestProcessStatusDelete() {
|
||||||
testStructs := suite.SetupTestStructs()
|
testStructs := suite.SetupTestStructs()
|
||||||
defer suite.TearDownTestStructs(testStructs)
|
defer suite.TearDownTestStructs(testStructs)
|
||||||
|
|
|
@ -30,10 +30,12 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// timelineAndNotifyStatus inserts the given status into the HOME
|
// 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
|
// It will also handle notifications for any mentions attached to
|
||||||
// the account, notifications for any local accounts that want
|
// 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{
|
follows = append(follows, >smodel.Follow{
|
||||||
AccountID: status.AccountID,
|
AccountID: status.AccountID,
|
||||||
Account: status.Account,
|
Account: status.Account,
|
||||||
Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself.
|
Notify: util.Ptr(false), // Account shouldn't notify itself.
|
||||||
ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs.
|
ShowReblogs: util.Ptr(true), // Account should show own reblogs.
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeline the status for each local follower of this account.
|
// Timeline the status for each local follower of this account.
|
||||||
// This will also handle notifying any followers with notify
|
// This will also handle notifying any followers with notify
|
||||||
// set to true on their follow.
|
// 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)
|
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.
|
// Notify each local account that's mentioned by this status.
|
||||||
if err := s.notifyMentions(ctx, status); err != nil {
|
if err := s.notifyMentions(ctx, status); err != nil {
|
||||||
return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err)
|
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
|
// adding the status to list timelines + home timelines of each
|
||||||
// follower, as appropriate, and notifying each follower of the
|
// follower, as appropriate, and notifying each follower of the
|
||||||
// new status, if the status is eligible for notification.
|
// 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(
|
func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
status *gtsmodel.Status,
|
status *gtsmodel.Status,
|
||||||
follows []*gtsmodel.Follow,
|
follows []*gtsmodel.Follow,
|
||||||
) error {
|
) ([]string, error) {
|
||||||
var (
|
var (
|
||||||
errs gtserror.MultiError
|
errs gtserror.MultiError
|
||||||
boost = status.BoostOfID != ""
|
boost = status.BoostOfID != ""
|
||||||
reply = status.InReplyToURI != ""
|
reply = status.InReplyToURI != ""
|
||||||
|
homeTimelinedAccountIDs = []string{}
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, follow := range follows {
|
for _, follow := range follows {
|
||||||
|
@ -122,17 +133,12 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
|
filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID)
|
||||||
if err != nil {
|
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
|
// Add status to any relevant lists
|
||||||
// for this follow, if applicable.
|
// for this follow, if applicable.
|
||||||
s.listTimelineStatusForFollow(
|
s.listTimelineStatusForFollow(
|
||||||
|
@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
follow,
|
follow,
|
||||||
&errs,
|
&errs,
|
||||||
filters,
|
filters,
|
||||||
compiledMutes,
|
mutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add status to home timeline for owner
|
// Add status to home timeline for owner
|
||||||
|
@ -154,7 +160,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
status,
|
status,
|
||||||
stream.TimelineHome,
|
stream.TimelineHome,
|
||||||
filters,
|
filters,
|
||||||
compiledMutes,
|
mutes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error home timelining status: %w", err)
|
errs.Appendf("error home timelining status: %w", err)
|
||||||
|
@ -166,6 +172,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
||||||
// timeline, we shouldn't notify it.
|
// timeline, we shouldn't notify it.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
|
||||||
|
|
||||||
if !*follow.Notify {
|
if !*follow.Notify {
|
||||||
// This follower doesn't have notifs
|
// 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
|
// 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
|
// listEligible checks if the given status is eligible
|
||||||
// for inclusion in the list that that the given listEntry
|
// for inclusion in the list that that the given listEntry
|
||||||
// belongs to, based on the replies policy of the list.
|
// belongs to, based on the replies policy of the list.
|
||||||
|
@ -391,6 +414,138 @@ func (s *Surface) timelineStatus(
|
||||||
return true, nil
|
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.
|
// deleteStatusFromTimelines completely removes the given status from all timelines.
|
||||||
// It will also stream deletion of the status to all open streams.
|
// It will also stream deletion of the status to all open streams.
|
||||||
func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error {
|
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
|
// 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.
|
// active streams.
|
||||||
// Note that calling invalidateStatusFromTimelines takes care of the
|
// Note that calling invalidateStatusFromTimelines takes care of the
|
||||||
// state in general, we just need to do this for any streams that are
|
// 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.
|
// 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)
|
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
|
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,
|
// slice of followers of the account that posted the given status,
|
||||||
// pushing update messages into open list/home streams of each
|
// pushing update messages into open list/home streams of each
|
||||||
// follower.
|
// follower.
|
||||||
|
//
|
||||||
|
// Returns a list of accounts which had this status updated in their home timelines.
|
||||||
func (s *Surface) timelineStatusUpdateForFollowers(
|
func (s *Surface) timelineStatusUpdateForFollowers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
status *gtsmodel.Status,
|
status *gtsmodel.Status,
|
||||||
follows []*gtsmodel.Follow,
|
follows []*gtsmodel.Follow,
|
||||||
) error {
|
) ([]string, error) {
|
||||||
var (
|
var (
|
||||||
errs gtserror.MultiError
|
errs gtserror.MultiError
|
||||||
|
homeTimelinedAccountIDs = []string{}
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, follow := range follows {
|
for _, follow := range follows {
|
||||||
|
@ -495,17 +658,12 @@ func (s *Surface) timelineStatusUpdateForFollowers(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID)
|
filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID)
|
||||||
if err != nil {
|
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
|
// Add status to any relevant lists
|
||||||
// for this follow, if applicable.
|
// for this follow, if applicable.
|
||||||
s.listTimelineStatusUpdateForFollow(
|
s.listTimelineStatusUpdateForFollow(
|
||||||
|
@ -514,26 +672,30 @@ func (s *Surface) timelineStatusUpdateForFollowers(
|
||||||
follow,
|
follow,
|
||||||
&errs,
|
&errs,
|
||||||
filters,
|
filters,
|
||||||
compiledMutes,
|
mutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add status to home timeline for owner
|
// Add status to home timeline for owner
|
||||||
// of this follow, if applicable.
|
// of this follow, if applicable.
|
||||||
err = s.timelineStreamStatusUpdate(
|
homeTimelined, err := s.timelineStreamStatusUpdate(
|
||||||
ctx,
|
ctx,
|
||||||
follow.Account,
|
follow.Account,
|
||||||
status,
|
status,
|
||||||
stream.TimelineHome,
|
stream.TimelineHome,
|
||||||
filters,
|
filters,
|
||||||
compiledMutes,
|
mutes,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error home timelining status: %w", err)
|
errs.Appendf("error home timelining status: %w", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if homeTimelined {
|
||||||
|
homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errs.Combine()
|
return homeTimelinedAccountIDs, errs.Combine()
|
||||||
}
|
}
|
||||||
|
|
||||||
// listTimelineStatusUpdateForFollow pushes edits of the given status
|
// listTimelineStatusUpdateForFollow pushes edits of the given status
|
||||||
|
@ -580,7 +742,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
|
||||||
// At this point we are certain this status
|
// At this point we are certain this status
|
||||||
// should be included in the timeline of the
|
// should be included in the timeline of the
|
||||||
// list that this list entry belongs to.
|
// list that this list entry belongs to.
|
||||||
if err := s.timelineStreamStatusUpdate(
|
if _, err := s.timelineStreamStatusUpdate(
|
||||||
ctx,
|
ctx,
|
||||||
follow.Account,
|
follow.Account,
|
||||||
status,
|
status,
|
||||||
|
@ -596,6 +758,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
|
||||||
|
|
||||||
// timelineStatusUpdate streams the edited status to the user using the
|
// timelineStatusUpdate streams the edited status to the user using the
|
||||||
// given streamType.
|
// given streamType.
|
||||||
|
//
|
||||||
|
// Returns whether it was actually streamed.
|
||||||
func (s *Surface) timelineStreamStatusUpdate(
|
func (s *Surface) timelineStreamStatusUpdate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
account *gtsmodel.Account,
|
account *gtsmodel.Account,
|
||||||
|
@ -603,16 +767,62 @@ func (s *Surface) timelineStreamStatusUpdate(
|
||||||
streamType string,
|
streamType string,
|
||||||
filters []*gtsmodel.Filter,
|
filters []*gtsmodel.Filter,
|
||||||
mutes *usermute.CompiledUserMuteList,
|
mutes *usermute.CompiledUserMuteList,
|
||||||
) error {
|
) (bool, error) {
|
||||||
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes)
|
apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes)
|
||||||
if errors.Is(err, statusfilter.ErrHideStatus) {
|
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||||
// Don't put this status in the stream.
|
// Don't put this status in the stream.
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
|
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)
|
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.
|
// 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.
|
// 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{
|
return apimodel.Tag{
|
||||||
Name: strings.ToLower(t.Name),
|
Name: strings.ToLower(t.Name),
|
||||||
URL: uris.URIForTag(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)
|
h := make([]any, 0)
|
||||||
return &h
|
return &h
|
||||||
}(),
|
}(),
|
||||||
|
Following: following,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2347,7 +2349,7 @@ func (c *Converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
|
||||||
|
|
||||||
// Convert GTS models to frontend models
|
// Convert GTS models to frontend models
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
apiTag, err := c.TagToAPITag(ctx, tag, false)
|
apiTag, err := c.TagToAPITag(ctx, tag, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error converting tag %s to api tag: %w", tag.ID, err)
|
errs.Appendf("error converting tag %s to api tag: %w", tag.ID, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -23,6 +23,7 @@ EXPECT=$(cat << "EOF"
|
||||||
"application-name": "gts",
|
"application-name": "gts",
|
||||||
"bind-address": "127.0.0.1",
|
"bind-address": "127.0.0.1",
|
||||||
"cache": {
|
"cache": {
|
||||||
|
"account-ids-following-tag-mem-ratio": 1,
|
||||||
"account-mem-ratio": 5,
|
"account-mem-ratio": 5,
|
||||||
"account-note-mem-ratio": 1,
|
"account-note-mem-ratio": 1,
|
||||||
"account-settings-mem-ratio": 0.1,
|
"account-settings-mem-ratio": 0.1,
|
||||||
|
@ -63,6 +64,7 @@ EXPECT=$(cat << "EOF"
|
||||||
"status-fave-ids-mem-ratio": 3,
|
"status-fave-ids-mem-ratio": 3,
|
||||||
"status-fave-mem-ratio": 2,
|
"status-fave-mem-ratio": 2,
|
||||||
"status-mem-ratio": 5,
|
"status-mem-ratio": 5,
|
||||||
|
"tag-ids-followed-by-account-mem-ratio": 1,
|
||||||
"tag-mem-ratio": 2,
|
"tag-mem-ratio": 2,
|
||||||
"thread-mute-mem-ratio": 0.2,
|
"thread-mute-mem-ratio": 0.2,
|
||||||
"token-mem-ratio": 0.75,
|
"token-mem-ratio": 0.75,
|
||||||
|
|
Loading…
Reference in New Issue