mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[feature] Implement following hashtags (#3141)
* Implement followed tags API * Insert statuses with followed tags into home timelines * Test following and unfollowing tags * Correct Swagger path params * Trim conversation caches * Migration for followed_tags table * Followed tag caches and DB implementation * Lint and tests * Add missing tag info endpoint, reorganize tag API * Unwrap boosts when timelining based on tags * Apply visibility filters to tag followers * Address review comments
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
if _, err := tx.
|
||||
NewCreateTable().
|
||||
Model(>smodel.FollowedTag{}).
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
@@ -19,9 +19,13 @@ package bundb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
@@ -131,3 +135,158 @@ func (t *tagDB) PutTag(ctx context.Context, tag *gtsmodel.Tag) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tagDB) GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) {
|
||||
tagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := t.GetTags(ctx, tagIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.TagIDsFollowedByAccount, accountID, page, func() ([]string, error) {
|
||||
var tagIDs []string
|
||||
|
||||
// Tag IDs not in cache. Perform DB query.
|
||||
if _, err := t.db.
|
||||
NewSelect().
|
||||
Model((*gtsmodel.FollowedTag)(nil)).
|
||||
Column("tag_id").
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
OrderExpr("? DESC", bun.Ident("tag_id")).
|
||||
Exec(ctx, &tagIDs); // nocollapse
|
||||
err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("error getting tag IDs followed by account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
return tagIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.AccountIDsFollowingTag, tagID, nil, func() ([]string, error) {
|
||||
var accountIDs []string
|
||||
|
||||
// Account IDs not in cache. Perform DB query.
|
||||
if _, err := t.db.
|
||||
NewSelect().
|
||||
Model((*gtsmodel.FollowedTag)(nil)).
|
||||
Column("account_id").
|
||||
Where("? = ?", bun.Ident("tag_id"), tagID).
|
||||
OrderExpr("? DESC", bun.Ident("account_id")).
|
||||
Exec(ctx, &accountIDs); // nocollapse
|
||||
err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("error getting account IDs following tag %s: %w", tagID, err)
|
||||
}
|
||||
|
||||
return accountIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (t *tagDB) IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) {
|
||||
accountTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, accountTagID := range accountTagIDs {
|
||||
if accountTagID == tagID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID string) error {
|
||||
// Insert the followed tag.
|
||||
result, err := t.db.NewInsert().
|
||||
Model(>smodel.FollowedTag{
|
||||
AccountID: accountID,
|
||||
TagID: tagID,
|
||||
}).
|
||||
On("CONFLICT (?, ?) DO NOTHING", bun.Ident("account_id"), bun.Ident("tag_id")).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error inserting followed tag: %w", err)
|
||||
}
|
||||
|
||||
// If it fails because that account already follows that tag, that's fine, and we're done.
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gtserror.Newf("error getting inserted row count: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Otherwise, this is a new followed tag, so we invalidate caches related to it.
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tagDB) DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error {
|
||||
result, err := t.db.NewDelete().
|
||||
Model((*gtsmodel.FollowedTag)(nil)).
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
Where("? = ?", bun.Ident("tag_id"), tagID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error deleting followed tag %s for account %s: %w", tagID, accountID, err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gtserror.Newf("error getting inserted row count: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we deleted anything, invalidate caches related to it.
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tagDB) DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error {
|
||||
// Delete followed tags from the database, returning the list of tag IDs affected.
|
||||
tagIDs := []string{}
|
||||
if err := t.db.NewDelete().
|
||||
Model((*gtsmodel.FollowedTag)(nil)).
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
Returning("?", bun.Ident("tag_id")).
|
||||
Scan(ctx, &tagIDs); // nocollapse
|
||||
err != nil {
|
||||
return gtserror.Newf("error deleting followed tags for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Invalidate account ID caches for the account and those tags.
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagIDs...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) {
|
||||
// Accounts might be following multiple tags in this list, but we only want to return each account once.
|
||||
accountIDs := []string{}
|
||||
for _, tagID := range tagIDs {
|
||||
tagAccountIDs, err := t.getAccountIDsFollowingTag(ctx, tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs = append(accountIDs, tagAccountIDs...)
|
||||
}
|
||||
return util.UniqueStrings(accountIDs), nil
|
||||
}
|
||||
|
@@ -21,6 +21,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// Tag contains functions for getting/creating tags in the database.
|
||||
@@ -36,4 +37,24 @@ type Tag interface {
|
||||
|
||||
// GetTags gets multiple tags.
|
||||
GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, error)
|
||||
|
||||
// GetFollowedTags gets the user's followed tags.
|
||||
GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error)
|
||||
|
||||
// IsAccountFollowingTag returns whether the account follows the given tag.
|
||||
IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error)
|
||||
|
||||
// PutFollowedTag creates a new followed tag for a the given user.
|
||||
// If it already exists, it returns without an error.
|
||||
PutFollowedTag(ctx context.Context, accountID string, tagID string) error
|
||||
|
||||
// DeleteFollowedTag deletes a followed tag for a the given user.
|
||||
// If no such followed tag exists, it returns without an error.
|
||||
DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error
|
||||
|
||||
// DeleteFollowedTagsByAccountID deletes all of an account's followed tags.
|
||||
DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error
|
||||
|
||||
// GetAccountIDsFollowingTagIDs returns the account IDs of any followers of the given tag IDs.
|
||||
GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error)
|
||||
}
|
||||
|
Reference in New Issue
Block a user