mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-10-29 19:02:26 -05:00
323 lines
8.6 KiB
Go
323 lines
8.6 KiB
Go
// 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 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"
|
|
)
|
|
|
|
type Processor struct {
|
|
state *state.State
|
|
converter *typeutils.Converter
|
|
visFilter *visibility.Filter
|
|
}
|
|
|
|
func New(state *state.State, converter *typeutils.Converter, visFilter *visibility.Filter) Processor {
|
|
return Processor{
|
|
state: state,
|
|
converter: converter,
|
|
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
|
|
}
|