[feature] Implement exclusive lists (#3280)

Fixes #2616
This commit is contained in:
Vyr Cossont 2024-09-09 15:56:58 -07:00 committed by GitHub
parent 5543fd5340
commit 540edef0c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 597 additions and 54 deletions

View file

@ -2060,6 +2060,12 @@ definitions:
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
list: list:
properties: 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: id:
description: The ID of the list. description: The ID of the list.
type: string type: string
@ -7799,10 +7805,20 @@ paths:
list = Show replies to members of the list list = Show replies to members of the list
none = Show replies to no one none = Show replies to no one
Sample: list Sample: list
enum:
- followed
- list
- none
in: formData in: formData
name: replies_policy name: replies_policy
type: string type: string
x-go-name: RepliesPolicy 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: produces:
- application/json - application/json
responses: responses:
@ -7920,6 +7936,10 @@ paths:
in: formData in: formData
name: replies_policy name: replies_policy
type: string type: string
- description: Hide posts from members of this list from your home timeline.
in: formData
name: exclusive
type: boolean
produces: produces:
- application/json - application/json
responses: responses:

View file

@ -89,7 +89,7 @@ func (suite *ListsTestSuite) getLists(targetAccountID string, expectedHTTPStatus
func (suite *ListsTestSuite) TestGetListsHit() { func (suite *ListsTestSuite) TestGetListsHit() {
targetAccount := suite.testAccounts["admin_account"] 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() { func (suite *ListsTestSuite) TestGetListsNoHit() {

View file

@ -46,6 +46,35 @@ import (
// produces: // produces:
// - application/json // - 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: // security:
// - OAuth2 Bearer: // - OAuth2 Bearer:
// - write:lists // - write:lists
@ -101,7 +130,13 @@ func (m *Module) ListCreatePOSTHandler(c *gin.Context) {
return 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 { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return

View file

@ -75,6 +75,11 @@ import (
// - list // - list
// - none // - none
// in: formData // in: formData
// -
// name: exclusive
// in: formData
// description: Hide posts from members of this list from your home timeline.
// type: boolean
// //
// security: // security:
// - OAuth2 Bearer: // - OAuth2 Bearer:
@ -146,13 +151,20 @@ func (m *Module) ListUpdatePUTHandler(c *gin.Context) {
repliesPolicy = &rp repliesPolicy = &rp
} }
if form.Title == nil && repliesPolicy == nil { if form.Title == nil && repliesPolicy == nil && form.Exclusive == nil {
err = errors.New("neither title nor replies_policy was set; nothing to update") 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) apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return 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 { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return

View file

@ -30,6 +30,9 @@ type List struct {
// list = Show replies to members of the list // list = Show replies to members of the list
// none = Show replies to no one // none = Show replies to no one
RepliesPolicy string `json:"replies_policy"` 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. // ListCreateRequest models list creation parameters.
@ -53,6 +56,11 @@ type ListCreateRequest struct {
// - list // - list
// - none // - none
RepliesPolicy string `form:"replies_policy" json:"replies_policy" xml:"replies_policy"` 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. // ListUpdateRequest models list update parameters.
@ -70,6 +78,10 @@ type ListUpdateRequest struct {
// Sample: list // Sample: list
// in: formData // in: formData
RepliesPolicy *string `form:"replies_policy" json:"replies_policy" xml:"replies_policy"` 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. // ListAccountsChangeRequest is a list of account IDs to add to or remove from a list.

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}
}

View file

@ -31,6 +31,7 @@ import (
) )
// 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. // 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) { func (f *Filter) StatusHomeTimelineable(ctx context.Context, owner *gtsmodel.Account, status *gtsmodel.Status) (bool, error) {
const vtype = cache.VisibilityTypeHome const vtype = cache.VisibilityTypeHome

View file

@ -29,6 +29,7 @@ type List struct {
Account *Account `bun:"-"` // Account corresponding to accountID Account *Account `bun:"-"` // Account corresponding to accountID
ListEntries []*ListEntry `bun:"-"` // Entries contained by this list. ListEntries []*ListEntry `bun:"-"` // Entries contained by this list.
RepliesPolicy RepliesPolicy `bun:",nullzero,notnull,default:'followed'"` // RepliesPolicy for 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. // ListEntry refers to a single follow entry in a list.

View file

@ -30,12 +30,19 @@ import (
// Create creates one a new list for the given account, using the provided parameters. // 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. // 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 := &gtsmodel.List{ list := &gtsmodel.List{
ID: id.NewULID(), ID: id.NewULID(),
Title: title, Title: title,
AccountID: account.ID, AccountID: account.ID,
RepliesPolicy: repliesPolicy, RepliesPolicy: repliesPolicy,
Exclusive: &exclusive,
} }
if err := p.state.DB.PutList(ctx, list); err != nil { if err := p.state.DB.PutList(ctx, list); err != nil {

View file

@ -47,7 +47,7 @@ func (p *Processor) Get(ctx context.Context, account *gtsmodel.Account, id strin
return p.apiList(ctx, list) 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) { func (p *Processor) GetAll(ctx context.Context, account *gtsmodel.Account) ([]*apimodel.List, gtserror.WithCode) {
lists, err := p.state.DB.GetListsForAccountID( lists, err := p.state.DB.GetListsForAccountID(
// Use barebones ctx; no embedded // Use barebones ctx; no embedded

View file

@ -36,6 +36,7 @@ func (p *Processor) Update(
id string, id string,
title *string, title *string,
repliesPolicy *gtsmodel.RepliesPolicy, repliesPolicy *gtsmodel.RepliesPolicy,
exclusive *bool,
) (*apimodel.List, gtserror.WithCode) { ) (*apimodel.List, gtserror.WithCode) {
list, errWithCode := p.getList( list, errWithCode := p.getList(
// Use barebones ctx; no embedded // Use barebones ctx; no embedded
@ -49,7 +50,7 @@ func (p *Processor) Update(
} }
// Only update columns we're told to update. // Only update columns we're told to update.
columns := make([]string, 0, 2) columns := make([]string, 0, 3)
if title != nil { if title != nil {
list.Title = *title list.Title = *title
@ -61,6 +62,11 @@ func (p *Processor) Update(
columns = append(columns, "replies_policy") 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 err := p.state.DB.UpdateList(ctx, list, columns...); err != nil {
if errors.Is(err, db.ErrAlreadyExists) { if errors.Is(err, db.ErrAlreadyExists) {
err = errors.New("you already have a list with this title") err = errors.New("you already have a list with this title")

View file

@ -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 = &gtsmodel.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 // 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. // should stream a status update to the tag-following user's home timeline.
func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() { func (suite *FromClientAPITestSuite) TestProcessUpdateStatusWithFollowedHashtag() {

View file

@ -34,7 +34,7 @@ import (
) )
// 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/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. // 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
@ -100,6 +100,7 @@ func (s *Surface) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.
// 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. // 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( func (s *Surface) timelineAndNotifyStatusForFollowers(
ctx context.Context, ctx context.Context,
status *gtsmodel.Status, status *gtsmodel.Status,
@ -118,8 +119,13 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// it's a reblog, whether follower account wants to see reblogs. // it's a reblog, whether follower account wants to see reblogs.
// //
// If it's not timelineable, we can just stop early, since lists // 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. // 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( timelineable, err := s.VisFilter.StatusHomeTimelineable(
ctx, follow.Account, status, ctx, follow.Account, status,
) )
@ -141,7 +147,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// Add status to any relevant lists // Add status to any relevant lists
// for this follow, if applicable. // for this follow, if applicable.
s.listTimelineStatusForFollow( exclusive, listTimelined := s.listTimelineStatusForFollow(
ctx, ctx,
status, status,
follow, follow,
@ -152,27 +158,32 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// Add status to home timeline for owner // Add status to home timeline for owner
// of this follow, if applicable. // of this follow, if applicable.
homeTimelined, err := s.timelineStatus( homeTimelined := false
ctx, if !exclusive {
s.State.Timelines.Home.IngestOne, homeTimelined, err = s.timelineStatus(
follow.AccountID, // home timelines are keyed by account ID ctx,
follow.Account, s.State.Timelines.Home.IngestOne,
status, follow.AccountID, // home timelines are keyed by account ID
stream.TimelineHome, follow.Account,
filters, status,
mutes, stream.TimelineHome,
) filters,
if err != nil { mutes,
errs.Appendf("error home timelining status: %w", err) )
continue if err != nil {
errs.Appendf("error home timelining status: %w", err)
continue
}
if homeTimelined {
homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
}
} }
if !homeTimelined { if !(homeTimelined || listTimelined) {
// If status wasn't added to home // If status wasn't added to home or list
// timeline, we shouldn't notify it. // timelines, 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
@ -188,7 +199,7 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// If we reach here, we know: // If we reach here, we know:
// //
// - This status is hometimelineable. // - 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 follower wants to be notified when this account posts.
// - This is a top-level post (not a reply or boost). // - This is a top-level post (not a reply or boost).
// //
@ -208,6 +219,10 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
// listTimelineStatusForFollow puts the given status // listTimelineStatusForFollow puts the given status
// in any eligible lists owned by the given follower. // 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( func (s *Surface) listTimelineStatusForFollow(
ctx context.Context, ctx context.Context,
status *gtsmodel.Status, status *gtsmodel.Status,
@ -215,7 +230,7 @@ func (s *Surface) listTimelineStatusForFollow(
errs *gtserror.MultiError, errs *gtserror.MultiError,
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
) { ) (bool, bool) {
// To put this status in appropriate list timelines, // To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to // we need to get each listEntry that pertains to
// this follow. Then, we want to iterate through all // 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 // that the entry belongs to if it meets criteria for
// inclusion in the list. // inclusion in the list.
// Get every list entry that targets this follow's ID. listEntries, err := s.getListEntries(ctx, follow)
listEntries, err := s.State.DB.GetListEntriesForFollowID( if err != nil {
// We only need the list IDs. errs.Append(err)
gtscontext.SetBarebones(ctx), return false, false
follow.ID, }
) exclusive, err := s.isAnyListExclusive(ctx, listEntries)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil {
errs.Appendf("error getting list entries: %w", err) errs.Append(err)
return return false, false
} }
// Check eligibility for each list entry (if any). // Check eligibility for each list entry (if any).
listTimelined := false
for _, listEntry := range listEntries { for _, listEntry := range listEntries {
eligible, err := s.listEligible(ctx, listEntry, status) eligible, err := s.listEligible(ctx, listEntry, status)
if err != nil { if err != nil {
@ -250,7 +266,7 @@ func (s *Surface) listTimelineStatusForFollow(
// 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.timelineStatus( timelined, err := s.timelineStatus(
ctx, ctx,
s.State.Timelines.List.IngestOne, s.State.Timelines.List.IngestOne,
listEntry.ListID, // list timelines are keyed by list ID 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 stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters, filters,
mutes, mutes,
); err != nil { )
if err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue // 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. // 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. // it's a reblog, whether follower account wants to see reblogs.
// //
// If it's not timelineable, we can just stop early, since lists // 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. // 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( timelineable, err := s.VisFilter.StatusHomeTimelineable(
ctx, follow.Account, status, ctx, follow.Account, status,
) )
@ -666,7 +735,7 @@ func (s *Surface) timelineStatusUpdateForFollowers(
// Add status to any relevant lists // Add status to any relevant lists
// for this follow, if applicable. // for this follow, if applicable.
s.listTimelineStatusUpdateForFollow( exclusive := s.listTimelineStatusUpdateForFollow(
ctx, ctx,
status, status,
follow, follow,
@ -675,6 +744,10 @@ func (s *Surface) timelineStatusUpdateForFollowers(
mutes, mutes,
) )
if exclusive {
continue
}
// Add status to home timeline for owner // Add status to home timeline for owner
// of this follow, if applicable. // of this follow, if applicable.
homeTimelined, err := s.timelineStreamStatusUpdate( homeTimelined, err := s.timelineStreamStatusUpdate(
@ -689,7 +762,6 @@ func (s *Surface) timelineStatusUpdateForFollowers(
errs.Appendf("error home timelining status: %w", err) errs.Appendf("error home timelining status: %w", err)
continue continue
} }
if homeTimelined { if homeTimelined {
homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID) homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
} }
@ -700,6 +772,9 @@ func (s *Surface) timelineStatusUpdateForFollowers(
// listTimelineStatusUpdateForFollow pushes edits of the given status // listTimelineStatusUpdateForFollow pushes edits of the given status
// into any eligible lists streams opened by the given follower. // 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( func (s *Surface) listTimelineStatusUpdateForFollow(
ctx context.Context, ctx context.Context,
status *gtsmodel.Status, status *gtsmodel.Status,
@ -707,7 +782,7 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
errs *gtserror.MultiError, errs *gtserror.MultiError,
filters []*gtsmodel.Filter, filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList, mutes *usermute.CompiledUserMuteList,
) { ) bool {
// To put this status in appropriate list timelines, // To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to // we need to get each listEntry that pertains to
// this follow. Then, we want to iterate through all // 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 // that the entry belongs to if it meets criteria for
// inclusion in the list. // inclusion in the list.
// Get every list entry that targets this follow's ID. listEntries, err := s.getListEntries(ctx, follow)
listEntries, err := s.State.DB.GetListEntriesForFollowID( if err != nil {
// We only need the list IDs. errs.Append(err)
gtscontext.SetBarebones(ctx), return false
follow.ID, }
) exclusive, err := s.isAnyListExclusive(ctx, listEntries)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil {
errs.Appendf("error getting list entries: %w", err) errs.Append(err)
return return false
} }
// Check eligibility for each list entry (if any). // Check eligibility for each list entry (if any).
@ -754,6 +829,8 @@ func (s *Surface) listTimelineStatusUpdateForFollow(
// implicit continue // implicit continue
} }
} }
return exclusive
} }
// timelineStatusUpdate streams the edited status to the user using the // timelineStatusUpdate streams the edited status to the user using the

View file

@ -2032,6 +2032,7 @@ func (c *Converter) ListToAPIList(ctx context.Context, l *gtsmodel.List) (*apimo
ID: l.ID, ID: l.ID,
Title: l.Title, Title: l.Title,
RepliesPolicy: string(l.RepliesPolicy), RepliesPolicy: string(l.RepliesPolicy),
Exclusive: *l.Exclusive,
}, nil }, nil
} }

View file

@ -2558,6 +2558,7 @@ func NewTestLists() map[string]*gtsmodel.List {
Title: "Cool Ass Posters From This Instance", Title: "Cool Ass Posters From This Instance",
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
RepliesPolicy: gtsmodel.RepliesPolicyFollowed, RepliesPolicy: gtsmodel.RepliesPolicyFollowed,
Exclusive: util.Ptr(false),
}, },
} }
} }