diff options
author | 2024-07-29 11:26:31 -0700 | |
---|---|---|
committer | 2024-07-29 19:26:31 +0100 | |
commit | a237e2b295fee71bdf7266520b0b6e0fb79b565c (patch) | |
tree | c522adc47019584b60de9420595505820635bb11 /internal/db | |
parent | [bugfix] take into account rotation when generating thumbnail (#3147) (diff) | |
download | gotosocial-a237e2b295fee71bdf7266520b0b6e0fb79b565c.tar.xz |
[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
Diffstat (limited to 'internal/db')
-rw-r--r-- | internal/db/bundb/migrations/20240725211933_add_followed_tags.go | 51 | ||||
-rw-r--r-- | internal/db/bundb/tag.go | 159 | ||||
-rw-r--r-- | internal/db/tag.go | 21 |
3 files changed, 231 insertions, 0 deletions
diff --git a/internal/db/bundb/migrations/20240725211933_add_followed_tags.go b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go new file mode 100644 index 000000000..f86b7d070 --- /dev/null +++ b/internal/db/bundb/migrations/20240725211933_add_followed_tags.go @@ -0,0 +1,51 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <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) + } +} diff --git a/internal/db/bundb/tag.go b/internal/db/bundb/tag.go index 5218a19d5..c6298ee64 100644 --- a/internal/db/bundb/tag.go +++ b/internal/db/bundb/tag.go @@ -19,9 +19,13 @@ package bundb import ( "context" + "errors" "strings" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/uptrace/bun" @@ -131,3 +135,158 @@ func (t *tagDB) PutTag(ctx context.Context, tag *gtsmodel.Tag) error { return nil } + +func (t *tagDB) GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) { + tagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, page) + if err != nil { + return nil, err + } + + tags, err := t.GetTags(ctx, tagIDs) + if err != nil { + return nil, err + } + + return tags, nil +} + +func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string, page *paging.Page) ([]string, error) { + return loadPagedIDs(&t.state.Caches.DB.TagIDsFollowedByAccount, accountID, page, func() ([]string, error) { + var tagIDs []string + + // Tag IDs not in cache. Perform DB query. + if _, err := t.db. + NewSelect(). + Model((*gtsmodel.FollowedTag)(nil)). + Column("tag_id"). + Where("? = ?", bun.Ident("account_id"), accountID). + OrderExpr("? DESC", bun.Ident("tag_id")). + Exec(ctx, &tagIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error getting tag IDs followed by account %s: %w", accountID, err) + } + + return tagIDs, nil + }) +} + +func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]string, error) { + return loadPagedIDs(&t.state.Caches.DB.AccountIDsFollowingTag, tagID, nil, func() ([]string, error) { + var accountIDs []string + + // Account IDs not in cache. Perform DB query. + if _, err := t.db. + NewSelect(). + Model((*gtsmodel.FollowedTag)(nil)). + Column("account_id"). + Where("? = ?", bun.Ident("tag_id"), tagID). + OrderExpr("? DESC", bun.Ident("account_id")). + Exec(ctx, &accountIDs); // nocollapse + err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("error getting account IDs following tag %s: %w", tagID, err) + } + + return accountIDs, nil + }) +} + +func (t *tagDB) IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) { + accountTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil) + if err != nil { + return false, err + } + + for _, accountTagID := range accountTagIDs { + if accountTagID == tagID { + return true, nil + } + } + + return false, nil +} + +func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID string) error { + // Insert the followed tag. + result, err := t.db.NewInsert(). + Model(>smodel.FollowedTag{ + AccountID: accountID, + TagID: tagID, + }). + On("CONFLICT (?, ?) DO NOTHING", bun.Ident("account_id"), bun.Ident("tag_id")). + Exec(ctx) + if err != nil { + return gtserror.Newf("error inserting followed tag: %w", err) + } + + // If it fails because that account already follows that tag, that's fine, and we're done. + rows, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting inserted row count: %w", err) + } + if rows == 0 { + return nil + } + + // Otherwise, this is a new followed tag, so we invalidate caches related to it. + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID) + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + + return nil +} + +func (t *tagDB) DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error { + result, err := t.db.NewDelete(). + Model((*gtsmodel.FollowedTag)(nil)). + Where("? = ?", bun.Ident("account_id"), accountID). + Where("? = ?", bun.Ident("tag_id"), tagID). + Exec(ctx) + if err != nil { + return gtserror.Newf("error deleting followed tag %s for account %s: %w", tagID, accountID, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return gtserror.Newf("error getting inserted row count: %w", err) + } + if rows == 0 { + return nil + } + + // If we deleted anything, invalidate caches related to it. + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID) + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + + return err +} + +func (t *tagDB) DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error { + // Delete followed tags from the database, returning the list of tag IDs affected. + tagIDs := []string{} + if err := t.db.NewDelete(). + Model((*gtsmodel.FollowedTag)(nil)). + Where("? = ?", bun.Ident("account_id"), accountID). + Returning("?", bun.Ident("tag_id")). + Scan(ctx, &tagIDs); // nocollapse + err != nil { + return gtserror.Newf("error deleting followed tags for account %s: %w", accountID, err) + } + + // Invalidate account ID caches for the account and those tags. + t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID) + t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagIDs...) + + return nil +} + +func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) { + // Accounts might be following multiple tags in this list, but we only want to return each account once. + accountIDs := []string{} + for _, tagID := range tagIDs { + tagAccountIDs, err := t.getAccountIDsFollowingTag(ctx, tagID) + if err != nil { + return nil, err + } + accountIDs = append(accountIDs, tagAccountIDs...) + } + return util.UniqueStrings(accountIDs), nil +} diff --git a/internal/db/tag.go b/internal/db/tag.go index c0642f5a4..66c880e86 100644 --- a/internal/db/tag.go +++ b/internal/db/tag.go @@ -21,6 +21,7 @@ import ( "context" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) // Tag contains functions for getting/creating tags in the database. @@ -36,4 +37,24 @@ type Tag interface { // GetTags gets multiple tags. GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, error) + + // GetFollowedTags gets the user's followed tags. + GetFollowedTags(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Tag, error) + + // IsAccountFollowingTag returns whether the account follows the given tag. + IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) + + // PutFollowedTag creates a new followed tag for a the given user. + // If it already exists, it returns without an error. + PutFollowedTag(ctx context.Context, accountID string, tagID string) error + + // DeleteFollowedTag deletes a followed tag for a the given user. + // If no such followed tag exists, it returns without an error. + DeleteFollowedTag(ctx context.Context, accountID string, tagID string) error + + // DeleteFollowedTagsByAccountID deletes all of an account's followed tags. + DeleteFollowedTagsByAccountID(ctx context.Context, accountID string) error + + // GetAccountIDsFollowingTagIDs returns the account IDs of any followers of the given tag IDs. + GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) } |