diff options
author | 2024-07-29 11:26:31 -0700 | |
---|---|---|
committer | 2024-07-29 19:26:31 +0100 | |
commit | a237e2b295fee71bdf7266520b0b6e0fb79b565c (patch) | |
tree | c522adc47019584b60de9420595505820635bb11 /internal/processing/workers | |
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/processing/workers')
-rw-r--r-- | internal/processing/workers/fromclientapi_test.go | 562 | ||||
-rw-r--r-- | internal/processing/workers/surfacetimeline.go | 290 |
2 files changed, 812 insertions, 40 deletions
diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index 35c2c31b7..b4eae0be0 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -52,6 +52,7 @@ func (suite *FromClientAPITestSuite) newStatus( boostOfStatus *gtsmodel.Status, mentionedAccounts []*gtsmodel.Account, createThread bool, + tagIDs []string, ) *gtsmodel.Status { var ( protocol = config.GetProtocol() @@ -65,6 +66,7 @@ func (suite *FromClientAPITestSuite) newStatus( URI: protocol + "://" + host + "/users/" + account.Username + "/statuses/" + statusID, URL: protocol + "://" + host + "/@" + account.Username + "/statuses/" + statusID, Content: "pee pee poo poo", + TagIDs: tagIDs, Local: util.Ptr(true), AccountURI: account.URI, AccountID: account.ID, @@ -256,6 +258,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() { nil, nil, false, + nil, ) ) @@ -367,6 +370,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReply() { nil, nil, false, + nil, ) ) @@ -428,6 +432,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyMuted() { nil, nil, false, + nil, ) threadMute = >smodel.ThreadMute{ ID: "01HD3KRMBB1M85QRWHD912QWRE", @@ -488,6 +493,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostMuted() { suite.testStatuses["local_account_1_status_1"], nil, false, + nil, ) threadMute = >smodel.ThreadMute{ ID: "01HD3KRMBB1M85QRWHD912QWRE", @@ -553,6 +559,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis nil, nil, false, + nil, ) ) @@ -628,6 +635,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusListRepliesPolicyLis nil, nil, false, + nil, ) ) @@ -708,6 +716,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusReplyListRepliesPoli nil, nil, false, + nil, ) ) @@ -780,6 +789,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoost() { suite.testStatuses["local_account_2_status_1"], nil, false, + nil, ) ) @@ -843,6 +853,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusBoostNoReblogs() { suite.testStatuses["local_account_2_status_1"], nil, false, + nil, ) ) @@ -912,6 +923,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichBeginsConversat nil, []*gtsmodel.Account{receivingAccount}, true, + nil, ) ) @@ -997,6 +1009,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate nil, []*gtsmodel.Account{receivingAccount}, true, + nil, ) ) @@ -1038,6 +1051,555 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWhichShouldNotCreate ) } +// A public status with a hashtag followed by a local user who does not otherwise follow the author +// should end up in the tag-following user's home timeline. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeUpdate, + ) +} + +// A public status with a hashtag followed by a local user who does not otherwise follow the author +// should not end up in the tag-following user's home timeline +// if the user has the author blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithFollowedHashtagAndBlock() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_1"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: postingAccount does not block receivingAccount. + blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks postingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the new status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityCreate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should end up in the tag-following user's home timeline as the original status. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_2"] + boostingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block boostingAccount or vice versa. + blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeUpdate, + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should not end up in the tag-following user's home timeline +// if the user has the author blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlock() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["remote_account_1"] + boostingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: postingAccount does not block receivingAccount. + blocking, err := testStructs.State.DB.IsBlocked(ctx, postingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks postingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block boostingAccount or vice versa. + blocking, err = testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A boost of a public status with a hashtag followed by a local user +// who does not otherwise follow the author or booster +// should not end up in the tag-following user's home timeline +// if the user has the booster blocked. +func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAndBlockedBoost() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + boostingAccount = suite.testAccounts["remote_account_1"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + + // boostingAccount boosts that status. + boost = suite.newStatus( + ctx, + testStructs.State, + boostingAccount, + gtsmodel.VisibilityPublic, + nil, + status, + nil, + false, + nil, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount does not follow boostingAccount. + following, err = testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: boostingAccount does not block receivingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, boostingAccount.ID, receivingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Check precondition: receivingAccount blocks boostingAccount. + blocking, err = testStructs.State.DB.IsBlocked(ctx, receivingAccount.ID, boostingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.True(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Process the boost. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ActivityAnnounce, + APActivityType: ap.ActivityCreate, + GTSModel: boost, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// Updating a public status with a hashtag followed by a local user who does not otherwise follow the author +// should stream a status update to the tag-following user's home timeline. +func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() { + testStructs := suite.SetupTestStructs() + defer suite.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["admin_account"] + receivingAccount = suite.testAccounts["local_account_2"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + nil, + ) + homeStream = streams[stream.TimelineHome] + testTag = suite.testTags["welcome"] + + // postingAccount posts a new public status not mentioning anyone but using testTag. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + []string{testTag.ID}, + ) + ) + + // Check precondition: receivingAccount does not follow postingAccount. + following, err := testStructs.State.DB.IsFollowing(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(following) + + // Check precondition: receivingAccount does not block postingAccount or vice versa. + blocking, err := testStructs.State.DB.IsEitherBlocked(ctx, receivingAccount.ID, postingAccount.ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.False(blocking) + + // Setup: receivingAccount follows testTag. + if err := testStructs.State.DB.PutFollowedTag(ctx, receivingAccount.ID, testTag.ID); err != nil { + suite.FailNow(err.Error()) + } + + // Update the status. + if err := testStructs.Processor.Workers().ProcessFromClientAPI( + ctx, + &messages.FromClientAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityUpdate, + GTSModel: status, + Origin: postingAccount, + }, + ); err != nil { + suite.FailNow(err.Error()) + } + + // Check status in home stream. + suite.checkStreamed( + homeStream, + true, + "", + stream.EventTypeStatusUpdate, + ) +} + func (suite *FromClientAPITestSuite) TestProcessStatusDelete() { testStructs := suite.SetupTestStructs() defer suite.TearDownTestStructs(testStructs) diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index 7bd0a51c6..c0987effd 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -30,10 +30,12 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/timeline" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // timelineAndNotifyStatus inserts the given status into the HOME -// and LIST timelines of accounts that follow the status author. +// and LIST timelines of accounts that follow the status author, +// as well as the HOME timelines of accounts that follow tags used by the status. // // It will also handle notifications for any mentions attached to // the account, notifications for any local accounts that want @@ -56,18 +58,24 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. follows = append(follows, >smodel.Follow{ AccountID: status.AccountID, Account: status.Account, - Notify: func() *bool { b := false; return &b }(), // Account shouldn't notify itself. - ShowReblogs: func() *bool { b := true; return &b }(), // Account should show own reblogs. + Notify: util.Ptr(false), // Account shouldn't notify itself. + ShowReblogs: util.Ptr(true), // Account should show own reblogs. }) } // Timeline the status for each local follower of this account. // This will also handle notifying any followers with notify // set to true on their follow. - if err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil { + homeTimelinedAccountIDs, err := s.timelineAndNotifyStatusForFollowers(ctx, status, follows) + if err != nil { return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) } + // Timeline the status for each local account who follows a tag used by this status. + if err := s.timelineAndNotifyStatusForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil { + return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err) + } + // Notify each local account that's mentioned by this status. if err := s.notifyMentions(ctx, status); err != nil { return gtserror.Newf("error notifying status mentions for status %s: %w", status.ID, err) @@ -90,15 +98,18 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. // adding the status to list timelines + home timelines of each // follower, as appropriate, and notifying each follower of the // new status, if the status is eligible for notification. +// +// Returns a list of accounts which had this status inserted into their home timelines. func (s *Surface) timelineAndNotifyStatusForFollowers( ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow, -) error { +) ([]string, error) { var ( - errs gtserror.MultiError - boost = status.BoostOfID != "" - reply = status.InReplyToURI != "" + errs gtserror.MultiError + boost = status.BoostOfID != "" + reply = status.InReplyToURI != "" + homeTimelinedAccountIDs = []string{} ) for _, follow := range follows { @@ -122,16 +133,11 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( continue } - filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) - if err != nil { - return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) - } - - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) + filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) + errs.Append(err) + continue } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) // Add status to any relevant lists // for this follow, if applicable. @@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( follow, &errs, filters, - compiledMutes, + mutes, ) // Add status to home timeline for owner @@ -154,7 +160,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( status, stream.TimelineHome, filters, - compiledMutes, + mutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) @@ -166,6 +172,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // timeline, we shouldn't notify it. continue } + homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) if !*follow.Notify { // This follower doesn't have notifs @@ -196,7 +203,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( } } - return errs.Combine() + return homeTimelinedAccountIDs, errs.Combine() } // listTimelineStatusForFollow puts the given status @@ -259,6 +266,22 @@ func (s *Surface) listTimelineStatusForFollow( } } +// getFiltersAndMutes returns an account's filters and mutes. +func (s *Surface) getFiltersAndMutes(ctx context.Context, accountID string) ([]*gtsmodel.Filter, *usermute.CompiledUserMuteList, error) { + filters, err := s.State.DB.GetFiltersForAccountID(ctx, accountID) + if err != nil { + return nil, nil, gtserror.Newf("couldn't retrieve filters for account %s: %w", accountID, err) + } + + mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), accountID, nil) + if err != nil { + return nil, nil, gtserror.Newf("couldn't retrieve mutes for account %s: %w", accountID, err) + } + compiledMutes := usermute.NewCompiledUserMuteList(mutes) + + return filters, compiledMutes, err +} + // listEligible checks if the given status is eligible // for inclusion in the list that that the given listEntry // belongs to, based on the replies policy of the list. @@ -391,6 +414,138 @@ func (s *Surface) timelineStatus( return true, nil } +// timelineAndNotifyStatusForTagFollowers inserts the status into the +// home timeline of each local account which follows a useable tag from the status, +// skipping accounts for which it would have already been inserted. +func (s *Surface) timelineAndNotifyStatusForTagFollowers( + ctx context.Context, + status *gtsmodel.Status, + alreadyHomeTimelinedAccountIDs []string, +) error { + tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs) + if err != nil { + return err + } + + if status.BoostOf != nil { + // Unwrap boost and work with the original status. + status = status.BoostOf + } + + // Insert the status into the home timeline of each tag follower. + errs := gtserror.MultiError{} + for _, tagFollowerAccount := range tagFollowerAccounts { + filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + if err != nil { + errs.Append(err) + continue + } + + if _, err := s.timelineStatus( + ctx, + s.State.Timelines.Home.IngestOne, + tagFollowerAccount.ID, // home timelines are keyed by account ID + tagFollowerAccount, + status, + stream.TimelineHome, + filters, + mutes, + ); err != nil { + errs.Appendf( + "error inserting status %s into home timeline for account %s: %w", + status.ID, + tagFollowerAccount.ID, + err, + ) + } + } + return errs.Combine() +} + +// tagFollowersForStatus gets local accounts which follow any useable tags from the status, +// skipping any with IDs in the provided list, and any that shouldn't be able to see it due to blocks. +func (s *Surface) tagFollowersForStatus( + ctx context.Context, + status *gtsmodel.Status, + skipAccountIDs []string, +) ([]*gtsmodel.Account, error) { + // If the status is a boost, look at the tags from the boosted status. + taggedStatus := status + if status.BoostOf != nil { + taggedStatus = status.BoostOf + } + + if taggedStatus.Visibility != gtsmodel.VisibilityPublic || len(taggedStatus.Tags) == 0 { + // Only public statuses with tags are eligible for tag processing. + return nil, nil + } + + // Build list of useable tag IDs. + useableTagIDs := make([]string, 0, len(taggedStatus.Tags)) + for _, tag := range taggedStatus.Tags { + if *tag.Useable { + useableTagIDs = append(useableTagIDs, tag.ID) + } + } + if len(useableTagIDs) == 0 { + return nil, nil + } + + // Get IDs for all accounts who follow one or more of the useable tags from this status. + allTagFollowerAccountIDs, err := s.State.DB.GetAccountIDsFollowingTagIDs(ctx, useableTagIDs) + if err != nil { + return nil, gtserror.Newf("DB error getting followers for tags of status %s: %w", taggedStatus.ID, err) + } + if len(allTagFollowerAccountIDs) == 0 { + return nil, nil + } + + // Build set for faster lookup of account IDs to skip. + skipAccountIDSet := make(map[string]struct{}, len(skipAccountIDs)) + for _, accountID := range skipAccountIDs { + skipAccountIDSet[accountID] = struct{}{} + } + + // Build list of tag follower account IDs, + // except those which have already had this status inserted into their timeline. + tagFollowerAccountIDs := make([]string, 0, len(allTagFollowerAccountIDs)) + for _, accountID := range allTagFollowerAccountIDs { + if _, skip := skipAccountIDSet[accountID]; skip { + continue + } + tagFollowerAccountIDs = append(tagFollowerAccountIDs, accountID) + } + if len(tagFollowerAccountIDs) == 0 { + return nil, nil + } + + // Retrieve accounts for remaining tag followers. + tagFollowerAccounts, err := s.State.DB.GetAccountsByIDs(ctx, tagFollowerAccountIDs) + if err != nil { + return nil, gtserror.Newf("DB error getting accounts for followers of tags of status %s: %w", taggedStatus.ID, err) + } + + // Check the visibility of the *input* status for each account. + // This accounts for the visibility of the boost as well as the original, if the input status is a boost. + errs := gtserror.MultiError{} + visibleTagFollowerAccounts := make([]*gtsmodel.Account, 0, len(tagFollowerAccounts)) + for _, account := range tagFollowerAccounts { + visible, err := s.VisFilter.StatusVisible(ctx, account, status) + if err != nil { + errs.Appendf( + "error checking visibility of status %s to account %s", + status.ID, + account.ID, + ) + } + if visible { + visibleTagFollowerAccounts = append(visibleTagFollowerAccounts, account) + } + } + + return visibleTagFollowerAccounts, errs.Combine() +} + // deleteStatusFromTimelines completely removes the given status from all timelines. // It will also stream deletion of the status to all open streams. func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error { @@ -425,7 +580,7 @@ func (s *Surface) invalidateStatusFromTimelines(ctx context.Context, statusID st } // timelineStatusUpdate looks up HOME and LIST timelines of accounts -// that follow the the status author and pushes edit messages into any +// that follow the the status author or tags and pushes edit messages into any // active streams. // Note that calling invalidateStatusFromTimelines takes care of the // state in general, we just need to do this for any streams that are @@ -454,10 +609,15 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta } // Push to streams for each local follower of this account. - if err := s.timelineStatusUpdateForFollowers(ctx, status, follows); err != nil { + homeTimelinedAccountIDs, err := s.timelineStatusUpdateForFollowers(ctx, status, follows) + if err != nil { return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) } + if err := s.timelineStatusUpdateForTagFollowers(ctx, status, homeTimelinedAccountIDs); err != nil { + return gtserror.Newf("error timelining status %s for tag followers: %w", status.ID, err) + } + return nil } @@ -465,13 +625,16 @@ func (s *Surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Sta // slice of followers of the account that posted the given status, // pushing update messages into open list/home streams of each // follower. +// +// Returns a list of accounts which had this status updated in their home timelines. func (s *Surface) timelineStatusUpdateForFollowers( ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow, -) error { +) ([]string, error) { var ( - errs gtserror.MultiError + errs gtserror.MultiError + homeTimelinedAccountIDs = []string{} ) for _, follow := range follows { @@ -495,16 +658,11 @@ func (s *Surface) timelineStatusUpdateForFollowers( continue } - filters, err := s.State.DB.GetFiltersForAccountID(ctx, follow.AccountID) - if err != nil { - return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err) - } - - mutes, err := s.State.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), follow.AccountID, nil) + filters, mutes, err := s.getFiltersAndMutes(ctx, follow.AccountID) if err != nil { - return gtserror.Newf("couldn't retrieve mutes for account %s: %w", follow.AccountID, err) + errs.Append(err) + continue } - compiledMutes := usermute.NewCompiledUserMuteList(mutes) // Add status to any relevant lists // for this follow, if applicable. @@ -514,26 +672,30 @@ func (s *Surface) timelineStatusUpdateForFollowers( follow, &errs, filters, - compiledMutes, + mutes, ) // Add status to home timeline for owner // of this follow, if applicable. - err = s.timelineStreamStatusUpdate( + homeTimelined, err := s.timelineStreamStatusUpdate( ctx, follow.Account, status, stream.TimelineHome, filters, - compiledMutes, + mutes, ) if err != nil { errs.Appendf("error home timelining status: %w", err) continue } + + if homeTimelined { + homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) + } } - return errs.Combine() + return homeTimelinedAccountIDs, errs.Combine() } // listTimelineStatusUpdateForFollow pushes edits of the given status @@ -580,7 +742,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // At this point we are certain this status // should be included in the timeline of the // list that this list entry belongs to. - if err := s.timelineStreamStatusUpdate( + if _, err := s.timelineStreamStatusUpdate( ctx, follow.Account, status, @@ -596,6 +758,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // timelineStatusUpdate streams the edited status to the user using the // given streamType. +// +// Returns whether it was actually streamed. func (s *Surface) timelineStreamStatusUpdate( ctx context.Context, account *gtsmodel.Account, @@ -603,16 +767,62 @@ func (s *Surface) timelineStreamStatusUpdate( streamType string, filters []*gtsmodel.Filter, mutes *usermute.CompiledUserMuteList, -) error { +) (bool, error) { apiStatus, err := s.Converter.StatusToAPIStatus(ctx, status, account, statusfilter.FilterContextHome, filters, mutes) if errors.Is(err, statusfilter.ErrHideStatus) { // Don't put this status in the stream. - return nil + return false, nil } if err != nil { err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) - return err + return false, err } s.Stream.StatusUpdate(ctx, account, apiStatus, streamType) - return nil + return true, nil +} + +// timelineStatusUpdateForTagFollowers streams update notifications to the +// home timeline of each local account which follows a tag used by the status, +// skipping accounts for which it would have already been streamed. +func (s *Surface) timelineStatusUpdateForTagFollowers( + ctx context.Context, + status *gtsmodel.Status, + alreadyHomeTimelinedAccountIDs []string, +) error { + tagFollowerAccounts, err := s.tagFollowersForStatus(ctx, status, alreadyHomeTimelinedAccountIDs) + if err != nil { + return err + } + + if status.BoostOf != nil { + // Unwrap boost and work with the original status. + status = status.BoostOf + } + + // Stream the update to the home timeline of each tag follower. + errs := gtserror.MultiError{} + for _, tagFollowerAccount := range tagFollowerAccounts { + filters, mutes, err := s.getFiltersAndMutes(ctx, tagFollowerAccount.ID) + if err != nil { + errs.Append(err) + continue + } + + if _, err := s.timelineStreamStatusUpdate( + ctx, + tagFollowerAccount, + status, + stream.TimelineHome, + filters, + mutes, + ); err != nil { + errs.Appendf( + "error updating status %s on home timeline for account %s: %w", + status.ID, + tagFollowerAccount.ID, + err, + ) + } + } + return errs.Combine() } |