start work rewriting timeline cache type

This commit is contained in:
kim 2024-12-30 17:12:55 +00:00
commit f4b4a696f2
23 changed files with 1792 additions and 861 deletions

View file

@ -20,131 +20,108 @@ package timeline
import (
"context"
"errors"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines.
func HomeTimelineGrab(state *state.State) timeline.GrabFunction {
return func(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
statuses, err := state.DB.GetHomeTimeline(ctx, accountID, maxID, sinceID, minID, limit, false)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses from db: %w", err)
return nil, false, err
}
// HomeTimelineGet ...
func (p *Processor) HomeTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
page *paging.Page,
local bool,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
count := len(statuses)
if count == 0 {
// We just don't have enough statuses
// left in the db so return stop = true.
return nil, true, nil
}
// Load timeline data.
return p.getTimeline(ctx,
items := make([]timeline.Timelineable, count)
for i, s := range statuses {
items[i] = s
}
// Auth'd
// account.
requester,
return items, false, nil
}
}
// Home timeline cache for authorized account.
p.state.Caches.Timelines.Home.Get(requester.ID),
// HomeTimelineFilter returns a function that satisfies FilterFunction for home timelines.
func HomeTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
return func(ctx context.Context, accountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
status, ok := item.(*gtsmodel.Status)
if !ok {
err = gtserror.New("could not convert item to *gtsmodel.Status")
return false, err
}
// Current
// page.
page,
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
return false, err
}
// Home timeline endpoint.
"/api/v1/timelines/home",
timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
if err != nil {
err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, accountID, err)
return false, err
}
// No page
// query.
nil,
return timelineable, nil
}
}
// Status filter context.
statusfilter.FilterContextHome,
// HomeTimelineStatusPrepare returns a function that satisfies PrepareFunction for home timelines.
func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
return func(ctx context.Context, accountID string, itemID string) (timeline.Preparable, error) {
status, err := state.DB.GetStatusByID(ctx, itemID)
if err != nil {
err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
return nil, err
}
// Timeline cache load function, used to further hydrate cache where necessary.
func(page *paging.Page) (statuses []*gtsmodel.Status, next *paging.Page, err error) {
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
return nil, err
}
// Fetch requesting account's home timeline page.
statuses, err = p.state.DB.GetHomeTimeline(ctx,
requester.ID,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, nil, gtserror.Newf("error getting statuses: %w", err)
}
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
if len(statuses) == 0 {
// No more to load.
return nil, nil, nil
}
mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
return nil, err
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
// Get the lowest and highest
// ID values, used for next pg.
lo := statuses[len(statuses)-1].ID
hi := statuses[0].ID
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
// Set next paging value.
page = page.Next(lo, hi)
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
statuses, err := p.state.Timelines.Home.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
for i := 0; i < len(statuses); {
// Get status at idx.
status := statuses[i]
count := len(statuses)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
// Check whether status should be show on home timeline.
visible, err := p.visFilter.StatusHomeTimelineable(ctx,
requester,
status,
)
if err != nil {
return nil, nil, gtserror.Newf("error checking visibility: %w", err)
}
var (
items = make([]interface{}, count)
nextMaxIDValue = statuses[count-1].GetID()
prevMinIDValue = statuses[0].GetID()
if !visible {
// Status not visible to home timeline.
statuses = slices.Delete(statuses, i, i+1)
continue
}
// Iter.
i++
}
return
},
// Per-request filtering function.
func(s *gtsmodel.Status) bool {
if local {
return !*s.Local
}
return false
},
)
for i := range statuses {
items[i] = statuses[i]
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/home",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
}

View file

@ -24,12 +24,9 @@ import (
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
tlprocessor "github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -37,20 +34,6 @@ type HomeTestSuite struct {
TimelineStandardTestSuite
}
func (suite *HomeTestSuite) SetupTest() {
suite.TimelineStandardTestSuite.SetupTest()
suite.state.Timelines.Home = timeline.NewManager(
tlprocessor.HomeTimelineGrab(&suite.state),
tlprocessor.HomeTimelineFilter(&suite.state, visibility.NewFilter(&suite.state)),
tlprocessor.HomeTimelineStatusPrepare(&suite.state, typeutils.NewConverter(&suite.state)),
tlprocessor.SkipInsert(),
)
if err := suite.state.Timelines.Home.Start(); err != nil {
suite.FailNow(err.Error())
}
}
func (suite *HomeTestSuite) TearDownTest() {
if err := suite.state.Timelines.Home.Stop(); err != nil {
suite.FailNow(err.Error())
@ -97,11 +80,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline to make sure the status we're going to filter is in that section of it.
resp, errWithCode := suite.timeline.HomeTimelineGet(
ctx,
authed,
maxID,
sinceID,
minID,
limit,
requester,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
suite.NoError(errWithCode)
@ -127,11 +111,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline again with the filter in place.
resp, errWithCode = suite.timeline.HomeTimelineGet(
ctx,
authed,
maxID,
sinceID,
minID,
limit,
requester,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)

View file

@ -20,157 +20,128 @@ package timeline
import (
"context"
"errors"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// ListTimelineGrab returns a function that satisfies GrabFunction for list timelines.
func ListTimelineGrab(state *state.State) timeline.GrabFunction {
return func(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
statuses, err := state.DB.GetListTimeline(ctx, listID, maxID, sinceID, minID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses from db: %w", err)
return nil, false, err
}
count := len(statuses)
if count == 0 {
// We just don't have enough statuses
// left in the db so return stop = true.
return nil, true, nil
}
items := make([]timeline.Timelineable, count)
for i, s := range statuses {
items[i] = s
}
return items, false, nil
}
}
// ListTimelineFilter returns a function that satisfies FilterFunction for list timelines.
func ListTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
return func(ctx context.Context, listID string, item timeline.Timelineable) (shouldIndex bool, err error) {
status, ok := item.(*gtsmodel.Status)
if !ok {
err = gtserror.New("could not convert item to *gtsmodel.Status")
return false, err
}
list, err := state.DB.GetListByID(ctx, listID)
if err != nil {
err = gtserror.Newf("error getting list with id %s: %w", listID, err)
return false, err
}
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
return false, err
}
timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
if err != nil {
err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, list.AccountID, err)
return false, err
}
return timelineable, nil
}
}
// ListTimelineStatusPrepare returns a function that satisfies PrepareFunction for list timelines.
func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
return func(ctx context.Context, listID string, itemID string) (timeline.Preparable, error) {
status, err := state.DB.GetStatusByID(ctx, itemID)
if err != nil {
err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
return nil, err
}
list, err := state.DB.GetListByID(ctx, listID)
if err != nil {
err = gtserror.Newf("error getting list with id %s: %w", listID, err)
return nil, err
}
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
return nil, err
}
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
return nil, err
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
func (p *Processor) ListTimelineGet(ctx context.Context, authed *apiutil.Auth, listID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
// Ensure list exists + is owned by this account.
list, err := p.state.DB.GetListByID(ctx, listID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
// ListTimelineGet ...
func (p *Processor) ListTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
listID string,
page *paging.Page,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
// Fetch the requested list with ID.
list, err := p.state.DB.GetListByID(
gtscontext.SetBarebones(ctx),
listID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
if list.AccountID != authed.Account.ID {
err = gtserror.Newf("list with id %s does not belong to account %s", list.ID, authed.Account.ID)
// Check exists.
if list != nil {
const text = "list not found"
return nil, gtserror.NewErrorNotFound(
errors.New(text),
text,
)
}
// Check list owned by auth'd account.
if list.AccountID != requester.ID {
err := gtserror.New("list does not belong to account")
return nil, gtserror.NewErrorNotFound(err)
}
statuses, err := p.state.Timelines.List.GetTimeline(ctx, listID, maxID, sinceID, minID, limit, false)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Load timeline data.
return p.getTimeline(ctx,
count := len(statuses)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
// Auth'd
// account.
requester,
var (
items = make([]interface{}, count)
nextMaxIDValue = statuses[count-1].GetID()
prevMinIDValue = statuses[0].GetID()
// List timeline cache for list with ID.
p.state.Caches.Timelines.List.Get(listID),
// Current
// page.
page,
// List timeline endpoint.
"/api/v1/timelines/list/"+listID,
// No page
// query.
nil,
// Status filter context.
statusfilter.FilterContextHome,
// Timeline cache load function, used to further hydrate cache where necessary.
func(page *paging.Page) (statuses []*gtsmodel.Status, next *paging.Page, err error) {
// Fetch requesting account's list timeline page.
statuses, err = p.state.DB.GetListTimeline(ctx,
listID,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, nil, gtserror.Newf("error getting statuses: %w", err)
}
if len(statuses) == 0 {
// No more to load.
return nil, nil, nil
}
// Get the lowest and highest
// ID values, used for next pg.
lo := statuses[len(statuses)-1].ID
hi := statuses[0].ID
// Set next paging value.
page = page.Next(lo, hi)
for i := 0; i < len(statuses); {
// Get status at idx.
status := statuses[i]
// Check whether status should be show on home timeline.
visible, err := p.visFilter.StatusHomeTimelineable(ctx,
requester,
status,
)
if err != nil {
return nil, nil, gtserror.Newf("error checking visibility: %w", err)
}
if !visible {
// Status not visible to home timeline.
statuses = slices.Delete(statuses, i, i+1)
continue
}
// Iter.
i++
}
return
},
// No furthering
// filter function.
nil,
)
for i := range statuses {
items[i] = statuses[i]
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/list/" + listID,
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
}

View file

@ -20,151 +20,108 @@ package timeline
import (
"context"
"errors"
"net/url"
"slices"
"strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// PublicTimelineGet ...
func (p *Processor) PublicTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
maxID string,
sinceID string,
minID string,
limit int,
page *paging.Page,
local bool,
) (*apimodel.PageableResponse, gtserror.WithCode) {
const maxAttempts = 3
var (
nextMaxIDValue string
prevMinIDValue string
items = make([]any, 0, limit)
)
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
var filters []*gtsmodel.Filter
var compiledMutes *usermute.CompiledUserMuteList
if requester != nil {
var err error
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Load timeline data.
return p.getTimeline(ctx,
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
compiledMutes = usermute.NewCompiledUserMuteList(mutes)
}
// Auth'd
// account.
requester,
// Try a few times to select appropriate public
// statuses from the db, paging up or down to
// reattempt if nothing suitable is found.
outer:
for attempts := 1; ; attempts++ {
// Select slightly more than the limit to try to avoid situations where
// we filter out all the entries, and have to make another db call.
// It's cheaper to select more in 1 query than it is to do multiple queries.
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit+5, local)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Global public timeline cache.
&p.state.Caches.Timelines.Public,
count := len(statuses)
if count == 0 {
// Nothing relevant (left) in the db.
return util.EmptyPageableResponse(), nil
}
// Current
// page.
page,
// Page up from first status in slice
// (ie., one with the highest ID).
prevMinIDValue = statuses[0].ID
// Public timeline endpoint.
"/api/v1/timelines/public",
inner:
for _, s := range statuses {
// Push back the next page down ID to
// this status, regardless of whether
// we end up filtering it out or not.
nextMaxIDValue = s.ID
// Set local-only timeline page query flag.
url.Values{"local": {strconv.FormatBool(local)}},
timelineable, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error checking status visibility: %v", err)
continue inner
// Status filter context.
statusfilter.FilterContextPublic,
// Timeline cache load function, used to further hydrate cache where necessary.
func(page *paging.Page) (statuses []*gtsmodel.Status, next *paging.Page, err error) {
// Fetch the global public status timeline page.
statuses, err = p.state.DB.GetPublicTimeline(ctx,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, nil, gtserror.Newf("error getting statuses: %w", err)
}
if !timelineable {
continue inner
if len(statuses) == 0 {
// No more to load.
return nil, nil, nil
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue inner
// Get the lowest and highest
// ID values, used for next pg.
lo := statuses[len(statuses)-1].ID
hi := statuses[0].ID
// Set next paging value.
page = page.Next(lo, hi)
for i := 0; i < len(statuses); {
// Get status at idx.
status := statuses[i]
// Check whether status should be show on public timeline.
visible, err := p.visFilter.StatusPublicTimelineable(ctx,
requester,
status,
)
if err != nil {
return nil, nil, gtserror.Newf("error checking visibility: %w", err)
}
if !visible {
// Status not visible to home timeline.
statuses = slices.Delete(statuses, i, i+1)
continue
}
// Iter.
i++
}
// Looks good, add this.
items = append(items, apiStatus)
// We called the db with a little
// more than the desired limit.
//
// Ensure we don't return more
// than the caller asked for.
if len(items) == limit {
break outer
}
}
if len(items) != 0 {
// We've got some items left after
// filtering, happily break + return.
break
}
if attempts >= maxAttempts {
// We reached our attempts limit.
// Be nice + warn about it.
log.Warn(ctx, "reached max attempts to find items in public timeline")
break
}
// We filtered out all items before we
// found anything we could return, but
// we still have attempts left to try
// fetching again. Set paging params
// and allow loop to continue.
if minID != "" {
// Paging up.
minID = prevMinIDValue
} else {
// Paging down.
maxID = nextMaxIDValue
}
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/public",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: []string{
"local=" + strconv.FormatBool(local),
return
},
})
// Per-request filtering function.
func(s *gtsmodel.Status) bool {
if local {
return !*s.Local
}
return false
},
)
}

View file

@ -25,6 +25,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -46,10 +47,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGet() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
@ -79,10 +81,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
@ -133,10 +136,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
suite.NoError(errWithCode)
@ -161,10 +165,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode = suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)

View file

@ -30,6 +30,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -58,7 +59,13 @@ func (p *Processor) TagTimelineGet(
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, maxID, sinceID, minID, limit)
page := paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
}
statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, &page)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)

View file

@ -18,7 +18,22 @@
package timeline
import (
"context"
"errors"
"net/url"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/cache"
"github.com/superseriousbusiness/gotosocial/internal/cache/timeline"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -36,3 +51,273 @@ func New(state *state.State, converter *typeutils.Converter, visFilter *visibili
visFilter: visFilter,
}
}
func (p *Processor) getStatusTimeline(
ctx context.Context,
requester *gtsmodel.Account,
timeline *timeline.StatusTimeline,
page *paging.Page,
pgPath string, // timeline page path
pgQuery url.Values, // timeline query parameters
filterCtx statusfilter.FilterContext,
loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error),
preFilter func(*gtsmodel.Status) (bool, error),
postFilter func(*timeline.StatusMeta) bool,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
var (
filters []*gtsmodel.Filter
mutes *usermute.CompiledUserMuteList
)
if requester != nil {
var err error
// Fetch all filters relevant for requesting account.
filters, err = p.state.DB.GetFiltersForAccountID(ctx,
requester.ID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account filters: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Get a list of all account mutes for requester.
allMutes, err := p.state.DB.GetAccountMutes(ctx,
requester.ID,
nil, // nil page, i.e. all
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account mutes: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Compile all account mutes to useable form.
mutes = usermute.NewCompiledUserMuteList(allMutes)
}
// ...
statuses, err := timeline.Load(ctx,
page,
// ...
loadPage,
// ...
func(ids []string) ([]*gtsmodel.Status, error) {
return p.state.DB.GetStatusesByIDs(ctx, ids)
},
// ...
preFilter,
// ...
postFilter,
// ...
func(status *gtsmodel.Status) (*apimodel.Status, error) {
apiStatus, err := p.converter.StatusToAPIStatus(ctx,
status,
requester,
filterCtx,
filters,
mutes,
)
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
return nil, err
}
return apiStatus, nil
},
)
if err != nil {
panic(err)
}
}
func (p *Processor) getTimeline(
ctx context.Context,
requester *gtsmodel.Account,
timeline *cache.TimelineCache[*gtsmodel.Status],
page *paging.Page,
pgPath string, // timeline page path
pgQuery url.Values, // timeline query parameters
filterCtx statusfilter.FilterContext,
load func(*paging.Page) (statuses []*gtsmodel.Status, next *paging.Page, err error), // timeline cache load function
filter func(*gtsmodel.Status) bool, // per-request filtering function, done AFTER timeline caching
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
// Load timeline with cache / loader funcs.
statuses, errWithCode := p.loadTimeline(ctx,
timeline,
page,
load,
filter,
)
if errWithCode != nil {
return nil, errWithCode
}
if len(statuses) == 0 {
// Check for an empty timeline rsp.
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := statuses[len(statuses)-1].ID
hi := statuses[0].ID
var (
filters []*gtsmodel.Filter
mutes *usermute.CompiledUserMuteList
)
if requester != nil {
var err error
// Fetch all filters relevant for requesting account.
filters, err = p.state.DB.GetFiltersForAccountID(ctx,
requester.ID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account filters: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Get a list of all account mutes for requester.
allMutes, err := p.state.DB.GetAccountMutes(ctx,
requester.ID,
nil, // nil page, i.e. all
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account mutes: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Compile all account mutes to useable form.
mutes = usermute.NewCompiledUserMuteList(allMutes)
}
// NOTE:
// Right now this is not ideal, as we perform mute and
// status filtering *after* the above load loop, so we
// could end up with no statuses still AFTER all loading.
//
// In a PR coming *soon* we will move the filtering and
// status muting into separate module similar to the visibility
// filtering and caching which should move it to the above
// load loop and provided function.
// API response requires them in interface{} form.
items := make([]interface{}, 0, len(statuses))
for _, status := range statuses {
// Convert internal status model to frontend model.
apiStatus, err := p.converter.StatusToAPIStatus(ctx,
status,
requester,
filterCtx,
filters,
mutes,
)
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
log.Errorf(ctx, "error converting status: %v", err)
continue
}
if apiStatus != nil {
// Append status to return slice.
items = append(items, apiStatus)
}
}
// Package converted API statuses as pageable response.
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Path: pgPath,
Query: pgQuery,
}), nil
}
func (p *Processor) loadTimeline(
ctx context.Context,
timeline *cache.TimelineCache[*gtsmodel.Status],
page *paging.Page,
load func(*paging.Page) (statuses []*gtsmodel.Status, next *paging.Page, err error),
filter func(*gtsmodel.Status) bool,
) (
[]*gtsmodel.Status,
gtserror.WithCode,
) {
if load == nil {
// nil check outside
// below main loop.
panic("nil func")
}
if page == nil {
const text = "timeline must be paged"
return nil, gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
}
// Try load statuses from cache.
statuses := timeline.Select(page)
// Filter statuses using provided function.
statuses = slices.DeleteFunc(statuses, filter)
// Check if more statuses need to be loaded.
if limit := page.Limit; len(statuses) < limit {
// Set first page
// query to load.
nextPg := page
for i := 0; i < 5; i++ {
var err error
var next []*gtsmodel.Status
// Load next timeline statuses.
next, nextPg, err = load(nextPg)
if err != nil {
err := gtserror.Newf("error loading timeline: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// An empty next page means no more.
if len(next) == 0 && nextPg == nil {
break
}
// Cache loaded statuses.
timeline.Insert(next...)
// Filter statuses using provided function,
// this must be done AFTER cache insert but
// BEFORE adding to slice, as this is used
// for request-specific timeline filtering,
// as opposed to filtering for entire cache.
next = slices.DeleteFunc(next, filter)
// Append loaded statuses to return.
statuses = append(statuses, next...)
if len(statuses) >= limit {
// We loaded all the statuses
// that were requested of us!
break
}
}
}
return statuses, nil
}