diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 07c939188..3db6acb99 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2060,6 +2060,12 @@ definitions: x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model list: properties: + exclusive: + description: |- + Exclusive setting for this list. + If true, hide posts from members of this list from your home timeline. + type: boolean + x-go-name: Exclusive id: description: The ID of the list. type: string @@ -7799,10 +7805,20 @@ paths: list = Show replies to members of the list none = Show replies to no one Sample: list + enum: + - followed + - list + - none in: formData name: replies_policy type: string x-go-name: RepliesPolicy + - default: false + description: Hide posts from members of this list from your home timeline. + in: formData + name: exclusive + type: boolean + x-go-name: Exclusive produces: - application/json responses: @@ -7920,6 +7936,10 @@ paths: in: formData name: replies_policy type: string + - description: Hide posts from members of this list from your home timeline. + in: formData + name: exclusive + type: boolean produces: - application/json responses: diff --git a/internal/api/client/accounts/lists_test.go b/internal/api/client/accounts/lists_test.go index 637babc35..95b50b1d3 100644 --- a/internal/api/client/accounts/lists_test.go +++ b/internal/api/client/accounts/lists_test.go @@ -89,7 +89,7 @@ func (suite *ListsTestSuite) getLists(targetAccountID string, expectedHTTPStatus func (suite *ListsTestSuite) TestGetListsHit() { targetAccount := suite.testAccounts["admin_account"] - suite.getLists(targetAccount.ID, http.StatusOK, `[{"id":"01H0G8E4Q2J3FE3JDWJVWEDCD1","title":"Cool Ass Posters From This Instance","replies_policy":"followed"}]`) + suite.getLists(targetAccount.ID, http.StatusOK, `[{"id":"01H0G8E4Q2J3FE3JDWJVWEDCD1","title":"Cool Ass Posters From This Instance","replies_policy":"followed","exclusive":false}]`) } func (suite *ListsTestSuite) TestGetListsNoHit() { diff --git a/internal/api/client/lists/listcreate.go b/internal/api/client/lists/listcreate.go index 9046ce34d..c8f547ccc 100644 --- a/internal/api/client/lists/listcreate.go +++ b/internal/api/client/lists/listcreate.go @@ -46,6 +46,35 @@ // produces: // - application/json // +// parameters: +// - +// name: title +// type: string +// description: |- +// Title of this list. +// Sample: Cool People +// in: formData +// - +// name: replies_policy +// type: string +// description: |- +// RepliesPolicy for this list. +// followed = Show replies to any followed user +// list = Show replies to members of the list +// none = Show replies to no one +// Sample: list +// enum: +// - followed +// - list +// - none +// in: formData +// - +// name: exclusive +// in: formData +// description: Hide posts from members of this list from your home timeline. +// type: boolean +// default: false +// // security: // - OAuth2 Bearer: // - write:lists @@ -101,7 +130,13 @@ func (m *Module) ListCreatePOSTHandler(c *gin.Context) { return } - apiList, errWithCode := m.processor.List().Create(c.Request.Context(), authed.Account, form.Title, repliesPolicy) + apiList, errWithCode := m.processor.List().Create( + c.Request.Context(), + authed.Account, + form.Title, + repliesPolicy, + form.Exclusive, + ) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/lists/listupdate.go b/internal/api/client/lists/listupdate.go index 312aa9ec7..38caa9621 100644 --- a/internal/api/client/lists/listupdate.go +++ b/internal/api/client/lists/listupdate.go @@ -75,6 +75,11 @@ // - list // - none // in: formData +// - +// name: exclusive +// in: formData +// description: Hide posts from members of this list from your home timeline. +// type: boolean // // security: // - OAuth2 Bearer: @@ -146,13 +151,20 @@ func (m *Module) ListUpdatePUTHandler(c *gin.Context) { repliesPolicy = &rp } - if form.Title == nil && repliesPolicy == nil { - err = errors.New("neither title nor replies_policy was set; nothing to update") + if form.Title == nil && repliesPolicy == nil && form.Exclusive == nil { + err = errors.New("neither title nor replies_policy nor exclusive was set; nothing to update") apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) return } - apiList, errWithCode := m.processor.List().Update(c.Request.Context(), authed.Account, targetListID, form.Title, repliesPolicy) + apiList, errWithCode := m.processor.List().Update( + c.Request.Context(), + authed.Account, + targetListID, + form.Title, + repliesPolicy, + form.Exclusive, + ) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/model/list.go b/internal/api/model/list.go index 03ea3420d..2f3e3855f 100644 --- a/internal/api/model/list.go +++ b/internal/api/model/list.go @@ -30,6 +30,9 @@ type List struct { // list = Show replies to members of the list // none = Show replies to no one RepliesPolicy string `json:"replies_policy"` + // Exclusive setting for this list. + // If true, hide posts from members of this list from your home timeline. + Exclusive bool `json:"exclusive"` } // ListCreateRequest models list creation parameters. @@ -53,6 +56,11 @@ type ListCreateRequest struct { // - list // - none RepliesPolicy string `form:"replies_policy" json:"replies_policy" xml:"replies_policy"` + // Exclusive setting for this list. + // If true, hide posts from members of this list from your home timeline. + // default: false + // in: formData + Exclusive bool `form:"exclusive" json:"exclusive" xml:"exclusive"` } // ListUpdateRequest models list update parameters. @@ -70,6 +78,10 @@ type ListUpdateRequest struct { // Sample: list // in: formData RepliesPolicy *string `form:"replies_policy" json:"replies_policy" xml:"replies_policy"` + // Exclusive setting for this list. + // If true, hide posts from members of this list from your home timeline. + // in: formData + Exclusive *bool `form:"exclusive" json:"exclusive" xml:"exclusive"` } // ListAccountsChangeRequest is a list of account IDs to add to or remove from a list. diff --git a/internal/db/bundb/migrations/20240903212842_add_exclusive_lists.go b/internal/db/bundb/migrations/20240903212842_add_exclusive_lists.go new file mode 100644 index 000000000..1326c75ff --- /dev/null +++ b/internal/db/bundb/migrations/20240903212842_add_exclusive_lists.go @@ -0,0 +1,59 @@ +// 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 . + +package migrations + +import ( + "context" + + "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 { + // Add the exclusive flag to lists. + tableName := "lists" + columnName := "exclusive" + + // If column already exists we don't need to do anything. + if exists, err := doesColumnExist(ctx, tx, tableName, columnName); err != nil { + return err + } else if exists { + return nil + } + + _, err := tx.ExecContext( + ctx, + "ALTER TABLE ? ADD COLUMN ? BOOLEAN DEFAULT FALSE", + bun.Ident(tableName), + bun.Ident(columnName), + ) + return err + }) + } + + 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/filter/visibility/home_timeline.go b/internal/filter/visibility/home_timeline.go index 9c224ffbb..37348e71d 100644 --- a/internal/filter/visibility/home_timeline.go +++ b/internal/filter/visibility/home_timeline.go @@ -31,6 +31,7 @@ ) // StatusHomeTimelineable checks if given status should be included on owner's home timeline. Primarily relying on status visibility to owner and the AP visibility setting, but also taking into account thread replies etc. +// Despite the name, statuses that ultimately end up in exclusive lists also need to be home-timelineable. func (f *Filter) StatusHomeTimelineable(ctx context.Context, owner *gtsmodel.Account, status *gtsmodel.Status) (bool, error) { const vtype = cache.VisibilityTypeHome diff --git a/internal/gtsmodel/list.go b/internal/gtsmodel/list.go index ea53df9b3..f99531ce8 100644 --- a/internal/gtsmodel/list.go +++ b/internal/gtsmodel/list.go @@ -29,6 +29,7 @@ type List struct { Account *Account `bun:"-"` // Account corresponding to accountID ListEntries []*ListEntry `bun:"-"` // Entries contained by this list. RepliesPolicy RepliesPolicy `bun:",nullzero,notnull,default:'followed'"` // RepliesPolicy for this list. + Exclusive *bool `bun:",nullzero,notnull,default:false"` // Hide posts from members of this list from your home timeline. } // ListEntry refers to a single follow entry in a list. diff --git a/internal/processing/list/create.go b/internal/processing/list/create.go index 10dec1050..dacd7909f 100644 --- a/internal/processing/list/create.go +++ b/internal/processing/list/create.go @@ -30,12 +30,19 @@ // Create creates one a new list for the given account, using the provided parameters. // These params should have already been validated by the time they reach this function. -func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, title string, repliesPolicy gtsmodel.RepliesPolicy) (*apimodel.List, gtserror.WithCode) { +func (p *Processor) Create( + ctx context.Context, + account *gtsmodel.Account, + title string, + repliesPolicy gtsmodel.RepliesPolicy, + exclusive bool, +) (*apimodel.List, gtserror.WithCode) { list := >smodel.List{ ID: id.NewULID(), Title: title, AccountID: account.ID, RepliesPolicy: repliesPolicy, + Exclusive: &exclusive, } if err := p.state.DB.PutList(ctx, list); err != nil { diff --git a/internal/processing/list/get.go b/internal/processing/list/get.go index 9a7e7716f..cdd3c6e0c 100644 --- a/internal/processing/list/get.go +++ b/internal/processing/list/get.go @@ -47,7 +47,7 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin return p.apiList(ctx, list) } -// GetMultiple returns multiple lists created by the given account, sorted by list ID DESC (newest first). +// GetAll returns multiple lists created by the given account, sorted by list ID DESC (newest first). func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.List, gtserror.WithCode) { lists, err := p.state.DB.GetListsForAccountID( // Use barebones ctx; no embedded diff --git a/internal/processing/list/update.go b/internal/processing/list/update.go index 656af1f78..408c334de 100644 --- a/internal/processing/list/update.go +++ b/internal/processing/list/update.go @@ -36,6 +36,7 @@ func (p *Processor) Update( id string, title *string, repliesPolicy *gtsmodel.RepliesPolicy, + exclusive *bool, ) (*apimodel.List, gtserror.WithCode) { list, errWithCode := p.getList( // Use barebones ctx; no embedded @@ -49,7 +50,7 @@ func (p *Processor) Update( } // Only update columns we're told to update. - columns := make([]string, 0, 2) + columns := make([]string, 0, 3) if title != nil { list.Title = *title @@ -61,6 +62,11 @@ func (p *Processor) Update( columns = append(columns, "replies_policy") } + if exclusive != nil { + list.Exclusive = exclusive + columns = append(columns, "exclusive") + } + if err := p.state.DB.UpdateList(ctx, list, columns...); err != nil { if errors.Is(err, db.ErrAlreadyExists) { err = errors.New("you already have a list with this title") diff --git a/internal/processing/workers/fromclientapi_test.go b/internal/processing/workers/fromclientapi_test.go index d330e4c2b..cc8801e1c 100644 --- a/internal/processing/workers/fromclientapi_test.go +++ b/internal/processing/workers/fromclientapi_test.go @@ -1527,6 +1527,317 @@ func (suite *FromClientAPITestSuite) TestProcessCreateBoostWithFollowedHashtagAn ) } +// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list +// should end up in the following user's timeline for that list, but not their home timeline. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiveList() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["local_account_2"] + receivingAccount = suite.testAccounts["local_account_1"] + testList = suite.testLists["local_account_1_list_1"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + []string{testList.ID}, + ) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + + // postingAccount posts a new public status not mentioning anyone. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + nil, + ) + ) + + // Setup: make the list exclusive. + // We modify the existing list rather than create a new one, so that there's only one list in play for this test. + list := new(gtsmodel.List) + *list = *testList + list.Exclusive = util.Ptr(true) + if err := testStructs.State.DB.UpdateList(ctx, list); 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 list stream. + suite.checkStreamed( + listStream, + true, + "", + stream.EventTypeUpdate, + ) + + // Check status not in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list +// should end up in the following user's timeline for that list, but not their home timeline. +// This should happen regardless of whether the author is on any of the following user's *non*-exclusive lists. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiveAndNonExclusiveLists() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["local_account_2"] + receivingAccount = suite.testAccounts["local_account_1"] + testInclusiveList = suite.testLists["local_account_1_list_1"] + testExclusiveList = >smodel.List{ + ID: id.NewULID(), + Title: "Cool Ass Posters From This Instance (exclusive)", + AccountID: receivingAccount.ID, + RepliesPolicy: gtsmodel.RepliesPolicyFollowed, + Exclusive: util.Ptr(true), + } + testFollow = suite.testFollows["local_account_1_local_account_2"] + testExclusiveListEntries = []*gtsmodel.ListEntry{ + { + ID: id.NewULID(), + ListID: testExclusiveList.ID, + FollowID: testFollow.ID, + }, + } + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + []string{ + testInclusiveList.ID, + testExclusiveList.ID, + }, + ) + homeStream = streams[stream.TimelineHome] + inclusiveListStream = streams[stream.TimelineList+":"+testInclusiveList.ID] + exclusiveListStream = streams[stream.TimelineList+":"+testExclusiveList.ID] + + // postingAccount posts a new public status not mentioning anyone. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + nil, + ) + ) + + // Precondition: the pre-existing inclusive list should actually be inclusive. + // This should be the case if we reset the DB correctly between tests in this file. + { + list, err := testStructs.State.DB.GetListByID(ctx, testInclusiveList.ID) + if err != nil { + suite.FailNow(err.Error()) + } + if *list.Exclusive { + suite.FailNowf( + "test precondition failed: list %s should be inclusive, but isn't", + testInclusiveList.ID, + ) + } + } + + // Setup: create the exclusive list and its list entry. + if err := testStructs.State.DB.PutList(ctx, testExclusiveList); err != nil { + suite.FailNow(err.Error()) + } + if err := testStructs.State.DB.PutListEntries(ctx, testExclusiveListEntries); 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 inclusive list stream. + suite.checkStreamed( + inclusiveListStream, + true, + "", + stream.EventTypeUpdate, + ) + + // Check status in exclusive list stream. + suite.checkStreamed( + exclusiveListStream, + true, + "", + stream.EventTypeUpdate, + ) + + // Check status not in home stream. + suite.checkStreamed( + homeStream, + false, + "", + "", + ) +} + +// A public status with a hashtag followed by a local user who follows the author and has them on an exclusive list +// should end up in the following user's timeline for that list, but not their home timeline. +// When they have notifications on for that user, they should be notified. +func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithAuthorOnExclusiveListAndNotificationsOn() { + testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath) + defer testrig.TearDownTestStructs(testStructs) + + var ( + ctx = context.Background() + postingAccount = suite.testAccounts["local_account_2"] + receivingAccount = suite.testAccounts["local_account_1"] + testFollow = suite.testFollows["local_account_1_local_account_2"] + testList = suite.testLists["local_account_1_list_1"] + streams = suite.openStreams(ctx, + testStructs.Processor, + receivingAccount, + []string{testList.ID}, + ) + homeStream = streams[stream.TimelineHome] + listStream = streams[stream.TimelineList+":"+testList.ID] + notifStream = streams[stream.TimelineNotifications] + + // postingAccount posts a new public status not mentioning anyone. + status = suite.newStatus( + ctx, + testStructs.State, + postingAccount, + gtsmodel.VisibilityPublic, + nil, + nil, + nil, + false, + nil, + ) + ) + + // Setup: Update the follow from receiving account -> posting account so + // that receiving account wants notifs when posting account posts. + follow := new(gtsmodel.Follow) + *follow = *testFollow + follow.Notify = util.Ptr(true) + if err := testStructs.State.DB.UpdateFollow(ctx, follow); err != nil { + suite.FailNow(err.Error()) + } + + // Setup: make the list exclusive. + list := new(gtsmodel.List) + *list = *testList + list.Exclusive = util.Ptr(true) + if err := testStructs.State.DB.UpdateList(ctx, list); 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 list stream. + suite.checkStreamed( + listStream, + true, + "", + stream.EventTypeUpdate, + ) + + // Wait for a notification to appear for the status. + var notif *gtsmodel.Notification + if !testrig.WaitFor(func() bool { + var err error + notif, err = testStructs.State.DB.GetNotification( + ctx, + gtsmodel.NotificationStatus, + receivingAccount.ID, + postingAccount.ID, + status.ID, + ) + return err == nil + }) { + suite.FailNow("timed out waiting for new status notification") + } + + apiNotif, err := testStructs.TypeConverter.NotificationToAPINotification(ctx, notif, nil, nil) + if err != nil { + suite.FailNow(err.Error()) + } + + notifJSON, err := json.Marshal(apiNotif) + if err != nil { + suite.FailNow(err.Error()) + } + + // Check message in notification stream. + suite.checkStreamed( + notifStream, + true, + string(notifJSON), + stream.EventTypeNotification, + ) + + // Check *notification* for status in home stream. + suite.checkStreamed( + homeStream, + true, + string(notifJSON), + stream.EventTypeNotification, + ) + + // Status itself should not be 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() { diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go index c0987effd..81544d928 100644 --- a/internal/processing/workers/surfacetimeline.go +++ b/internal/processing/workers/surfacetimeline.go @@ -34,7 +34,7 @@ ) // timelineAndNotifyStatus inserts the given status into the HOME -// and LIST timelines of accounts that follow the status author, +// and/or 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 @@ -100,6 +100,7 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel. // new status, if the status is eligible for notification. // // Returns a list of accounts which had this status inserted into their home timelines. +// This will be used to prevent duplicate inserts when handling followed tags. func (s *Surface) timelineAndNotifyStatusForFollowers( ctx context.Context, status *gtsmodel.Status, @@ -118,8 +119,13 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // it's a reblog, whether follower account wants to see reblogs. // // If it's not timelineable, we can just stop early, since lists - // are prettymuch subsets of the home timeline, so if it shouldn't + // are pretty much subsets of the home timeline, so if it shouldn't // appear there, it shouldn't appear in lists either. + // + // Exclusive lists don't change this: + // if something is hometimelineable according to this filter, + // it's also eligible to appear in exclusive lists, + // even if it ultimately doesn't appear on the home timeline. timelineable, err := s.VisFilter.StatusHomeTimelineable( ctx, follow.Account, status, ) @@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // Add status to any relevant lists // for this follow, if applicable. - s.listTimelineStatusForFollow( + exclusive, listTimelined := s.listTimelineStatusForFollow( ctx, status, follow, @@ -152,27 +158,32 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // Add status to home timeline for owner // of this follow, if applicable. - homeTimelined, err := s.timelineStatus( - ctx, - s.State.Timelines.Home.IngestOne, - follow.AccountID, // home timelines are keyed by account ID - follow.Account, - status, - stream.TimelineHome, - filters, - mutes, - ) - if err != nil { - errs.Appendf("error home timelining status: %w", err) - continue + homeTimelined := false + if !exclusive { + homeTimelined, err = s.timelineStatus( + ctx, + s.State.Timelines.Home.IngestOne, + follow.AccountID, // home timelines are keyed by account ID + follow.Account, + status, + stream.TimelineHome, + filters, + mutes, + ) + if err != nil { + errs.Appendf("error home timelining status: %w", err) + continue + } + if homeTimelined { + homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) + } } - if !homeTimelined { - // If status wasn't added to home - // timeline, we shouldn't notify it. + if !(homeTimelined || listTimelined) { + // If status wasn't added to home or list + // timelines, we shouldn't notify it. continue } - homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) if !*follow.Notify { // This follower doesn't have notifs @@ -188,7 +199,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // If we reach here, we know: // // - This status is hometimelineable. - // - This status was added to the home timeline for this follower. + // - This status was added to the home timeline and/or list timelines for this follower. // - This follower wants to be notified when this account posts. // - This is a top-level post (not a reply or boost). // @@ -208,6 +219,10 @@ func (s *Surface) timelineAndNotifyStatusForFollowers( // listTimelineStatusForFollow puts the given status // in any eligible lists owned by the given follower. +// +// It returns whether the status was added to any lists, +// and whether the status author is on any exclusive lists +// (in which case the status shouldn't be added to the home timeline). func (s *Surface) listTimelineStatusForFollow( ctx context.Context, status *gtsmodel.Status, @@ -215,7 +230,7 @@ func (s *Surface) listTimelineStatusForFollow( errs *gtserror.MultiError, filters []*gtsmodel.Filter, mutes *usermute.CompiledUserMuteList, -) { +) (bool, bool) { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to // this follow. Then, we want to iterate through all @@ -223,18 +238,19 @@ func (s *Surface) listTimelineStatusForFollow( // that the entry belongs to if it meets criteria for // inclusion in the list. - // Get every list entry that targets this follow's ID. - listEntries, err := s.State.DB.GetListEntriesForFollowID( - // We only need the list IDs. - gtscontext.SetBarebones(ctx), - follow.ID, - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - errs.Appendf("error getting list entries: %w", err) - return + listEntries, err := s.getListEntries(ctx, follow) + if err != nil { + errs.Append(err) + return false, false + } + exclusive, err := s.isAnyListExclusive(ctx, listEntries) + if err != nil { + errs.Append(err) + return false, false } // Check eligibility for each list entry (if any). + listTimelined := false for _, listEntry := range listEntries { eligible, err := s.listEligible(ctx, listEntry, status) if err != nil { @@ -250,7 +266,7 @@ func (s *Surface) listTimelineStatusForFollow( // 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.timelineStatus( + timelined, err := s.timelineStatus( ctx, s.State.Timelines.List.IngestOne, listEntry.ListID, // list timelines are keyed by list ID @@ -259,11 +275,59 @@ func (s *Surface) listTimelineStatusForFollow( stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list filters, mutes, - ); err != nil { + ) + if err != nil { errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) // implicit continue } + listTimelined = listTimelined || timelined } + + return exclusive, listTimelined +} + +// getListEntries returns list entries for a given follow. +func (s *Surface) getListEntries(ctx context.Context, follow *gtsmodel.Follow) ([]*gtsmodel.ListEntry, error) { + // Get every list entry that targets this follow's ID. + listEntries, err := s.State.DB.GetListEntriesForFollowID( + // We only need the list IDs. + gtscontext.SetBarebones(ctx), + follow.ID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("DB error getting list entries: %v", err) + } + return listEntries, nil +} + +// isAnyListExclusive determines whether any provided list entry corresponds to an exclusive list. +func (s *Surface) isAnyListExclusive(ctx context.Context, listEntries []*gtsmodel.ListEntry) (bool, error) { + if len(listEntries) == 0 { + return false, nil + } + + listIDs := make([]string, 0, len(listEntries)) + for _, listEntry := range listEntries { + listIDs = append(listIDs, listEntry.ListID) + } + lists, err := s.State.DB.GetListsByIDs( + // We only need the list exclusive flags. + gtscontext.SetBarebones(ctx), + listIDs, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, gtserror.Newf("DB error getting lists for list entries: %v", err) + } + + if len(lists) == 0 { + return false, nil + } + for _, list := range lists { + if *list.Exclusive { + return true, nil + } + } + return false, nil } // getFiltersAndMutes returns an account's filters and mutes. @@ -643,8 +707,13 @@ func (s *Surface) timelineStatusUpdateForFollowers( // it's a reblog, whether follower account wants to see reblogs. // // If it's not timelineable, we can just stop early, since lists - // are prettymuch subsets of the home timeline, so if it shouldn't + // are pretty much subsets of the home timeline, so if it shouldn't // appear there, it shouldn't appear in lists either. + // + // Exclusive lists don't change this: + // if something is hometimelineable according to this filter, + // it's also eligible to appear in exclusive lists, + // even if it ultimately doesn't appear on the home timeline. timelineable, err := s.VisFilter.StatusHomeTimelineable( ctx, follow.Account, status, ) @@ -666,7 +735,7 @@ func (s *Surface) timelineStatusUpdateForFollowers( // Add status to any relevant lists // for this follow, if applicable. - s.listTimelineStatusUpdateForFollow( + exclusive := s.listTimelineStatusUpdateForFollow( ctx, status, follow, @@ -675,6 +744,10 @@ func (s *Surface) timelineStatusUpdateForFollowers( mutes, ) + if exclusive { + continue + } + // Add status to home timeline for owner // of this follow, if applicable. homeTimelined, err := s.timelineStreamStatusUpdate( @@ -689,7 +762,6 @@ func (s *Surface) timelineStatusUpdateForFollowers( errs.Appendf("error home timelining status: %w", err) continue } - if homeTimelined { homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) } @@ -700,6 +772,9 @@ func (s *Surface) timelineStatusUpdateForFollowers( // listTimelineStatusUpdateForFollow pushes edits of the given status // into any eligible lists streams opened by the given follower. +// +// It returns whether the status author is on any exclusive lists +// (in which case the status shouldn't be added to the home timeline). func (s *Surface) listTimelineStatusUpdateForFollow( ctx context.Context, status *gtsmodel.Status, @@ -707,7 +782,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow( errs *gtserror.MultiError, filters []*gtsmodel.Filter, mutes *usermute.CompiledUserMuteList, -) { +) bool { // To put this status in appropriate list timelines, // we need to get each listEntry that pertains to // this follow. Then, we want to iterate through all @@ -715,15 +790,15 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // that the entry belongs to if it meets criteria for // inclusion in the list. - // Get every list entry that targets this follow's ID. - listEntries, err := s.State.DB.GetListEntriesForFollowID( - // We only need the list IDs. - gtscontext.SetBarebones(ctx), - follow.ID, - ) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - errs.Appendf("error getting list entries: %w", err) - return + listEntries, err := s.getListEntries(ctx, follow) + if err != nil { + errs.Append(err) + return false + } + exclusive, err := s.isAnyListExclusive(ctx, listEntries) + if err != nil { + errs.Append(err) + return false } // Check eligibility for each list entry (if any). @@ -754,6 +829,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow( // implicit continue } } + + return exclusive } // timelineStatusUpdate streams the edited status to the user using the diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 5cbed62e0..395603949 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -2032,6 +2032,7 @@ func (c *Converter) ListToAPIList(ctx context.Context, l *gtsmodel.List) (*apimo ID: l.ID, Title: l.Title, RepliesPolicy: string(l.RepliesPolicy), + Exclusive: *l.Exclusive, }, nil } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 171851d09..ea3d96a04 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -2558,6 +2558,7 @@ func NewTestLists() map[string]*gtsmodel.List { Title: "Cool Ass Posters From This Instance", AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", RepliesPolicy: gtsmodel.RepliesPolicyFollowed, + Exclusive: util.Ptr(false), }, } }