mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-12-03 10:18:06 -06:00
Merge branch 'main' into focal_point
This commit is contained in:
commit
c7fc66abae
108 changed files with 2935 additions and 5213 deletions
|
|
@ -30,7 +30,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
|
|
@ -79,12 +78,6 @@ func (suite *EmojiGetTestSuite) SetupTest() {
|
|||
suite.state.Storage = suite.storage
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
|
|
@ -86,12 +85,6 @@ func (suite *UserStandardTestSuite) SetupTest() {
|
|||
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -88,12 +86,6 @@ func (suite *AccountStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -94,12 +92,6 @@ func (suite *AdminStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -100,12 +99,6 @@ func (suite *BookmarkTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,9 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/exports"
|
||||
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/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -69,12 +67,6 @@ func (suite *ExportsTestSuite) SetupTest() {
|
|||
suite.state.DB = testrig.NewTestDB(&suite.state)
|
||||
suite.state.Storage = testrig.NewInMemoryStorage()
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.state.DB, nil)
|
||||
testrig.StandardStorageSetup(suite.state.Storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
|
|
@ -84,12 +83,6 @@ func (suite *FavouritesStandardTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -29,14 +29,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -93,12 +91,6 @@ func (suite *FiltersTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -29,14 +29,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -93,12 +91,6 @@ func (suite *FiltersTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../../testrig/media")), suite.mediaManager)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../../web/template/", suite.sentEmails)
|
||||
|
|
|
|||
|
|
@ -30,14 +30,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -85,12 +83,6 @@ func (suite *FollowRequestStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
|
||||
|
|
|
|||
|
|
@ -28,11 +28,9 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
|
||||
importdata "github.com/superseriousbusiness/gotosocial/internal/api/client/import"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -67,12 +65,6 @@ func (suite *ImportTestSuite) SetupTest() {
|
|||
suite.state.DB = testrig.NewTestDB(&suite.state)
|
||||
suite.state.Storage = testrig.NewInMemoryStorage()
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.state.DB, nil)
|
||||
testrig.StandardStorageSetup(suite.state.Storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -30,14 +30,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -87,12 +85,6 @@ func (suite *InstanceStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"bytes"
|
||||
|
||||
"codeberg.org/gruf/go-bytes"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
|
|
|
|||
|
|
@ -24,13 +24,11 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -90,12 +88,6 @@ func (suite *ListsStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -93,12 +92,6 @@ func (suite *MediaCreateTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -91,12 +90,6 @@ func (suite *MediaUpdateTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -84,12 +82,6 @@ func (suite *MutesTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
|
|
@ -86,12 +85,6 @@ func (suite *NotificationsTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -24,13 +24,11 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -80,12 +78,6 @@ func (suite *PollsStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -24,13 +24,11 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -79,12 +77,6 @@ func (suite *ReportsStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -30,14 +30,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -83,12 +81,6 @@ func (suite *SearchStandardTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
|
|
@ -197,12 +196,6 @@ func (suite *StatusStandardTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -97,12 +96,6 @@ func (suite *StreamingTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// HomeTimelineGETHandler swagger:operation GET /api/v1/timelines/home homeTimeline
|
||||
|
|
@ -127,13 +128,17 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||
local, errWithCode := apiutil.ParseLocal(c.Query(apiutil.LocalKey), false)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
local, errWithCode := apiutil.ParseLocal(c.Query(apiutil.LocalKey), false)
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
40, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -141,11 +146,8 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
|
|||
|
||||
resp, errWithCode := m.processor.Timeline().HomeTimelineGet(
|
||||
c.Request.Context(),
|
||||
authed,
|
||||
c.Query(apiutil.MaxIDKey),
|
||||
c.Query(apiutil.SinceIDKey),
|
||||
c.Query(apiutil.MinIDKey),
|
||||
limit,
|
||||
authed.Account,
|
||||
page,
|
||||
local,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// ListTimelineGETHandler swagger:operation GET /api/v1/timelines/list/{id} listTimeline
|
||||
|
|
@ -131,7 +132,11 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {
|
|||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
40, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -139,12 +144,9 @@ func (m *Module) ListTimelineGETHandler(c *gin.Context) {
|
|||
|
||||
resp, errWithCode := m.processor.Timeline().ListTimelineGet(
|
||||
c.Request.Context(),
|
||||
authed,
|
||||
authed.Account,
|
||||
targetListID,
|
||||
c.Query(apiutil.MaxIDKey),
|
||||
c.Query(apiutil.SinceIDKey),
|
||||
c.Query(apiutil.MinIDKey),
|
||||
limit,
|
||||
page,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import (
|
|||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// PublicTimelineGETHandler swagger:operation GET /api/v1/timelines/public publicTimeline
|
||||
|
|
@ -141,7 +142,11 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 20, 40, 1)
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
40, // max limit
|
||||
20, // default limit
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -156,10 +161,7 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
|
|||
resp, errWithCode := m.processor.Timeline().PublicTimelineGet(
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
c.Query(apiutil.MaxIDKey),
|
||||
c.Query(apiutil.SinceIDKey),
|
||||
c.Query(apiutil.MinIDKey),
|
||||
limit,
|
||||
page,
|
||||
local,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -77,12 +76,6 @@ func (suite *UserStandardTestSuite) SetupTest() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(
|
||||
&suite.state,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
|
|
@ -92,12 +91,6 @@ func (suite *FileserverTestSuite) SetupSuite() {
|
|||
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.oauthServer = testrig.NewTestOauthServer(&suite.state)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../web/template/", nil)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
|
|
@ -81,12 +80,6 @@ func (suite *WebfingerStandardTestSuite) SetupTest() {
|
|||
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
9
internal/cache/cache.go
vendored
9
internal/cache/cache.go
vendored
|
|
@ -46,6 +46,9 @@ type Caches struct {
|
|||
// `[status.ID][status.UpdatedAt.Unix()]`
|
||||
StatusesFilterableFields *ttl.Cache[string, []string]
|
||||
|
||||
// Timelines ...
|
||||
Timelines TimelineCaches
|
||||
|
||||
// Visibility provides access to the item visibility
|
||||
// cache. (used by the visibility filter).
|
||||
Visibility VisibilityCache
|
||||
|
|
@ -87,12 +90,14 @@ func (c *Caches) Init() {
|
|||
c.initFollowRequest()
|
||||
c.initFollowRequestIDs()
|
||||
c.initFollowingTagIDs()
|
||||
c.initHomeTimelines()
|
||||
c.initInReplyToIDs()
|
||||
c.initInstance()
|
||||
c.initInteractionRequest()
|
||||
c.initList()
|
||||
c.initListIDs()
|
||||
c.initListedIDs()
|
||||
c.initListTimelines()
|
||||
c.initMarker()
|
||||
c.initMedia()
|
||||
c.initMention()
|
||||
|
|
@ -109,6 +114,7 @@ func (c *Caches) Init() {
|
|||
c.initStatusEdit()
|
||||
c.initStatusFave()
|
||||
c.initStatusFaveIDs()
|
||||
c.initStatusesFilterableFields()
|
||||
c.initTag()
|
||||
c.initThreadMute()
|
||||
c.initToken()
|
||||
|
|
@ -120,7 +126,6 @@ func (c *Caches) Init() {
|
|||
c.initWebPushSubscription()
|
||||
c.initWebPushSubscriptionIDs()
|
||||
c.initVisibility()
|
||||
c.initStatusesFilterableFields()
|
||||
}
|
||||
|
||||
// Start will start any caches that require a background
|
||||
|
|
@ -207,6 +212,8 @@ func (c *Caches) Sweep(threshold float64) {
|
|||
c.DB.User.Trim(threshold)
|
||||
c.DB.UserMute.Trim(threshold)
|
||||
c.DB.UserMuteIDs.Trim(threshold)
|
||||
c.Timelines.Home.Trim()
|
||||
c.Timelines.List.Trim()
|
||||
c.Visibility.Trim(threshold)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,36 +15,37 @@
|
|||
// 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
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/cache/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
func (t *timeline) Unprepare(ctx context.Context, itemID string) error {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
type TimelineCaches struct {
|
||||
// Home provides a concurrency-safe map of status timeline
|
||||
// caches for home timelines, keyed by home's account ID.
|
||||
Home timeline.StatusTimelines
|
||||
|
||||
if t.items == nil || t.items.data == nil {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID != itemID && entry.boostOfID != itemID {
|
||||
// Not relevant.
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.prepared == nil {
|
||||
// It's already unprepared (mood).
|
||||
continue
|
||||
}
|
||||
|
||||
entry.prepared = nil // <- eat this up please garbage collector nom nom nom
|
||||
}
|
||||
|
||||
return nil
|
||||
// List provides a concurrency-safe map of status
|
||||
// timeline caches for lists, keyed by list ID.
|
||||
List timeline.StatusTimelines
|
||||
}
|
||||
|
||||
func (c *Caches) initHomeTimelines() {
|
||||
// TODO: configurable
|
||||
cap := 800
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.Timelines.Home.Init(cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initListTimelines() {
|
||||
// TODO: configurable
|
||||
cap := 800
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.Timelines.List.Init(cap)
|
||||
}
|
||||
152
internal/cache/timeline/preload.go
vendored
Normal file
152
internal/cache/timeline/preload.go
vendored
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// 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 (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
// preloader provides a means of synchronising the
|
||||
// initial fill, or "preload", of a timeline cache.
|
||||
// it has 4 possible states in the atomic pointer:
|
||||
// - preloading = &(interface{}(*sync.WaitGroup))
|
||||
// - preloaded = &(interface{}(nil))
|
||||
// - needs preload = &(interface{}(false))
|
||||
// - brand-new = nil (functionally same as 'needs preload')
|
||||
type preloader struct{ p atomic.Pointer[any] }
|
||||
|
||||
// Check will return the current preload state,
|
||||
// waiting if a preload is currently in progress.
|
||||
func (p *preloader) Check() bool {
|
||||
for {
|
||||
// Get state ptr.
|
||||
ptr := p.p.Load()
|
||||
|
||||
// Check if requires preloading.
|
||||
if ptr == nil || *ptr == false {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for a preload currently in progress.
|
||||
if wg, _ := (*ptr).(*sync.WaitGroup); wg != nil {
|
||||
wg.Wait()
|
||||
continue
|
||||
}
|
||||
|
||||
// Anything else
|
||||
// means success.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPreload will safely check the preload state,
|
||||
// and if needed call the provided function. if a
|
||||
// preload is in progress, it will wait until complete.
|
||||
func (p *preloader) CheckPreload(preload func(*any)) {
|
||||
for {
|
||||
// Get state ptr.
|
||||
ptr := p.p.Load()
|
||||
|
||||
if ptr == nil || *ptr == false {
|
||||
// Needs preloading, start it.
|
||||
ok := p.start(ptr, preload)
|
||||
|
||||
if !ok {
|
||||
// Failed to acquire start,
|
||||
// other thread beat us to it.
|
||||
continue
|
||||
}
|
||||
|
||||
// Success!
|
||||
return
|
||||
}
|
||||
|
||||
// Check for a preload currently in progress.
|
||||
if wg, _ := (*ptr).(*sync.WaitGroup); wg != nil {
|
||||
wg.Wait()
|
||||
continue
|
||||
}
|
||||
|
||||
// Anything else
|
||||
// means success.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// start attempts to start the given preload function, by performing
|
||||
// a compare and swap operation with 'old'. return is success.
|
||||
func (p *preloader) start(old *any, preload func(*any)) bool {
|
||||
|
||||
// Optimistically setup a
|
||||
// new waitgroup to set as
|
||||
// the preload waiter.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
defer wg.Done()
|
||||
|
||||
// Wrap waitgroup in
|
||||
// 'any' for pointer.
|
||||
new := any(&wg)
|
||||
ptr := &new
|
||||
|
||||
// Attempt CAS operation to claim start.
|
||||
started := p.p.CompareAndSwap(old, ptr)
|
||||
if !started {
|
||||
return false
|
||||
}
|
||||
|
||||
// Start.
|
||||
preload(ptr)
|
||||
return true
|
||||
}
|
||||
|
||||
// done marks state as preloaded,
|
||||
// i.e. no more preload required.
|
||||
func (p *preloader) Done(ptr *any) {
|
||||
if !p.p.CompareAndSwap(ptr, new(any)) {
|
||||
log.Errorf(nil, "BUG: invalid preloader state: %#v", (*p.p.Load()))
|
||||
}
|
||||
}
|
||||
|
||||
// clear will clear the state, marking a "preload" as required.
|
||||
// i.e. next call to Check() will call provided preload func.
|
||||
func (p *preloader) Clear() {
|
||||
b := false
|
||||
a := any(b)
|
||||
for {
|
||||
// Load current ptr.
|
||||
ptr := p.p.Load()
|
||||
if ptr == nil {
|
||||
return // was brand-new
|
||||
}
|
||||
|
||||
// Check for a preload currently in progress.
|
||||
if wg, _ := (*ptr).(*sync.WaitGroup); wg != nil {
|
||||
wg.Wait()
|
||||
continue
|
||||
}
|
||||
|
||||
// Try mark as needing preload.
|
||||
if p.p.CompareAndSwap(ptr, &a) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
842
internal/cache/timeline/status.go
vendored
Normal file
842
internal/cache/timeline/status.go
vendored
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
// 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"
|
||||
"slices"
|
||||
|
||||
"codeberg.org/gruf/go-structr"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"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/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||
)
|
||||
|
||||
// repeatBoostDepth determines the minimum count
|
||||
// of statuses after which repeat boosts, or boosts
|
||||
// of the original, may appear. This is may not end
|
||||
// up *exact*, as small races between insert and the
|
||||
// repeatBoost calculation may allow 1 or so extra
|
||||
// to sneak in ahead of time. but it mostly works!
|
||||
const repeatBoostDepth = 40
|
||||
|
||||
// StatusMeta contains minimum viable metadata
|
||||
// about a Status in order to cache a timeline.
|
||||
type StatusMeta struct {
|
||||
ID string
|
||||
AccountID string
|
||||
BoostOfID string
|
||||
BoostOfAccountID string
|
||||
|
||||
// is an internal flag that may be set on
|
||||
// a StatusMeta object that will prevent
|
||||
// preparation of its apimodel.Status, due
|
||||
// to it being a recently repeated boost.
|
||||
repeatBoost bool
|
||||
|
||||
// prepared contains prepared frontend API
|
||||
// model for the referenced status. This may
|
||||
// or may-not be nil depending on whether the
|
||||
// status has been "unprepared" since the last
|
||||
// call to "prepare" the frontend model.
|
||||
prepared *apimodel.Status
|
||||
|
||||
// loaded is a temporary field that may be
|
||||
// set for a newly loaded timeline status
|
||||
// so that statuses don't need to be loaded
|
||||
// from the database twice in succession.
|
||||
//
|
||||
// i.e. this will only be set if the status
|
||||
// was newly inserted into the timeline cache.
|
||||
// for existing cache items this will be nil.
|
||||
loaded *gtsmodel.Status
|
||||
}
|
||||
|
||||
// StatusTimeline provides a concurrency-safe sliding-window
|
||||
// cache of the freshest statuses in a timeline. Internally,
|
||||
// only StatusMeta{} objects themselves are stored, loading
|
||||
// the actual statuses when necessary, but caching prepared
|
||||
// frontend API models where possible.
|
||||
//
|
||||
// Notes on design:
|
||||
//
|
||||
// Previously, and initially when designing this newer type,
|
||||
// we had status timeline caches that would dynamically fill
|
||||
// themselves with statuses on call to Load() with statuses
|
||||
// at *any* location in the timeline, while simultaneously
|
||||
// accepting new input of statuses from the background workers.
|
||||
// This unfortunately can lead to situations where posts need
|
||||
// to be fetched from the database, but the cache isn't aware
|
||||
// they exist and instead returns an incomplete selection.
|
||||
// This problem is best outlined by the follow simple example:
|
||||
//
|
||||
// "what if my timeline cache contains posts 0-to-6 and 8-to-12,
|
||||
// and i make a request for posts between 4-and-10 with no limit,
|
||||
// how is it to know that it's missing post 7?"
|
||||
//
|
||||
// The solution is to unfortunately remove a lot of the caching
|
||||
// of "older areas" of the timeline, and instead just have it
|
||||
// be a sliding window of the freshest posts of that timeline.
|
||||
// It gets preloaded initially on start / first-call, and kept
|
||||
// up-to-date with new posts by streamed inserts from background
|
||||
// workers. Any requests for posts outside this we know therefore
|
||||
// must hit the database, (which we then *don't* cache).
|
||||
type StatusTimeline struct {
|
||||
|
||||
// underlying timeline cache of *StatusMeta{},
|
||||
// primary-keyed by ID, with extra indices below.
|
||||
cache structr.Timeline[*StatusMeta, string]
|
||||
|
||||
// preloader synchronizes preload
|
||||
// state of the timeline cache.
|
||||
preloader preloader
|
||||
|
||||
// fast-access cache indices.
|
||||
idx_ID *structr.Index //nolint:revive
|
||||
idx_AccountID *structr.Index //nolint:revive
|
||||
idx_BoostOfID *structr.Index //nolint:revive
|
||||
idx_BoostOfAccountID *structr.Index //nolint:revive
|
||||
|
||||
// cutoff and maximum item lengths.
|
||||
// the timeline is trimmed back to
|
||||
// cutoff on each call to Trim(),
|
||||
// and maximum len triggers a Trim().
|
||||
//
|
||||
// the timeline itself does not
|
||||
// limit items due to complexities
|
||||
// it would introduce, so we apply
|
||||
// a 'cut-off' at regular intervals.
|
||||
cut, max int
|
||||
}
|
||||
|
||||
// Init will initialize the timeline for usage,
|
||||
// by preparing internal indices etc. This also
|
||||
// sets the given max capacity for Trim() operations.
|
||||
func (t *StatusTimeline) Init(cap int) {
|
||||
t.cache.Init(structr.TimelineConfig[*StatusMeta, string]{
|
||||
|
||||
// Timeline item primary key field.
|
||||
PKey: structr.IndexConfig{Fields: "ID"},
|
||||
|
||||
// Additional indexed fields.
|
||||
Indices: []structr.IndexConfig{
|
||||
{Fields: "AccountID", Multiple: true},
|
||||
{Fields: "BoostOfAccountID", Multiple: true},
|
||||
{Fields: "BoostOfID", Multiple: true},
|
||||
},
|
||||
|
||||
// Timeline item copy function.
|
||||
Copy: func(s *StatusMeta) *StatusMeta {
|
||||
var prepared *apimodel.Status
|
||||
if s.prepared != nil {
|
||||
prepared = new(apimodel.Status)
|
||||
*prepared = *s.prepared
|
||||
}
|
||||
return &StatusMeta{
|
||||
ID: s.ID,
|
||||
AccountID: s.AccountID,
|
||||
BoostOfID: s.BoostOfID,
|
||||
BoostOfAccountID: s.BoostOfAccountID,
|
||||
repeatBoost: s.repeatBoost,
|
||||
loaded: nil, // NEVER stored
|
||||
prepared: prepared,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Get fast index lookup ptrs.
|
||||
t.idx_ID = t.cache.Index("ID")
|
||||
t.idx_AccountID = t.cache.Index("AccountID")
|
||||
t.idx_BoostOfID = t.cache.Index("BoostOfID")
|
||||
t.idx_BoostOfAccountID = t.cache.Index("BoostOfAccountID")
|
||||
|
||||
// Set maximum capacity and
|
||||
// cutoff threshold we trim to.
|
||||
t.cut = int(0.60 * float64(cap))
|
||||
t.max = cap
|
||||
}
|
||||
|
||||
// Preload will fill with StatusTimeline{} cache with
|
||||
// the latest sliding window of status metadata for the
|
||||
// timeline type returned by database 'loadPage' function.
|
||||
//
|
||||
// This function is concurrency-safe and repeated calls to
|
||||
// it when already preloaded will be no-ops. To trigger a
|
||||
// preload as being required, call .Clear().
|
||||
func (t *StatusTimeline) Preload(
|
||||
|
||||
// loadPage should load the timeline of given page for cache hydration.
|
||||
loadPage func(page *paging.Page) (statuses []*gtsmodel.Status, err error),
|
||||
|
||||
// filter can be used to perform filtering of returned
|
||||
// statuses BEFORE insert into cache. i.e. this will effect
|
||||
// what actually gets stored in the timeline cache.
|
||||
filter func(each *gtsmodel.Status) (delete bool),
|
||||
) (
|
||||
n int,
|
||||
err error,
|
||||
) {
|
||||
t.preloader.CheckPreload(func(ptr *any) {
|
||||
n, err = t.preload(loadPage, filter)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as preloaded.
|
||||
t.preloader.Done(ptr)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// preload contains the core logic of
|
||||
// Preload(), without t.preloader checks.
|
||||
func (t *StatusTimeline) preload(
|
||||
|
||||
// loadPage should load the timeline of given page for cache hydration.
|
||||
loadPage func(page *paging.Page) (statuses []*gtsmodel.Status, err error),
|
||||
|
||||
// filter can be used to perform filtering of returned
|
||||
// statuses BEFORE insert into cache. i.e. this will effect
|
||||
// what actually gets stored in the timeline cache.
|
||||
filter func(each *gtsmodel.Status) (delete bool),
|
||||
) (int, error) {
|
||||
if loadPage == nil {
|
||||
panic("nil load page func")
|
||||
}
|
||||
|
||||
// Clear timeline
|
||||
// before preload.
|
||||
t.cache.Clear()
|
||||
|
||||
// Our starting, page at the top
|
||||
// of the possible timeline.
|
||||
page := new(paging.Page)
|
||||
order := paging.OrderDescending
|
||||
page.Max.Order = order
|
||||
page.Max.Value = plus1hULID()
|
||||
page.Min.Order = order
|
||||
page.Min.Value = ""
|
||||
page.Limit = 100
|
||||
|
||||
// Prepare a slice for gathering status meta.
|
||||
metas := make([]*StatusMeta, 0, page.Limit)
|
||||
|
||||
var n int
|
||||
for n < t.cut {
|
||||
// Load page of timeline statuses.
|
||||
statuses, err := loadPage(page)
|
||||
if err != nil {
|
||||
return n, gtserror.Newf("error loading statuses: %w", err)
|
||||
}
|
||||
|
||||
// No more statuses from
|
||||
// load function = at end.
|
||||
if len(statuses) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Update our next page cursor from statuses.
|
||||
page.Max.Value = statuses[len(statuses)-1].ID
|
||||
|
||||
// Perform any filtering on newly loaded statuses.
|
||||
statuses = doStatusFilter(statuses, filter)
|
||||
|
||||
// After filtering no more
|
||||
// statuses remain, retry.
|
||||
if len(statuses) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert statuses to meta and insert.
|
||||
metas = toStatusMeta(metas[:0], statuses)
|
||||
n = t.cache.Insert(metas...)
|
||||
}
|
||||
|
||||
// This is a potentially 100-1000s size map,
|
||||
// but still easily manageable memory-wise.
|
||||
recentBoosts := make(map[string]int, t.cut)
|
||||
|
||||
// Iterate timeline ascending (i.e. oldest -> newest), marking
|
||||
// entry IDs and marking down if boosts have been seen recently.
|
||||
for idx, value := range t.cache.RangeUnsafe(structr.Asc) {
|
||||
|
||||
// Store current ID in map.
|
||||
recentBoosts[value.ID] = idx
|
||||
|
||||
// If it's a boost, check if the original,
|
||||
// or a boost of it has been seen recently.
|
||||
if id := value.BoostOfID; id != "" {
|
||||
|
||||
// Check if seen recently.
|
||||
last, ok := recentBoosts[id]
|
||||
repeat := ok && (idx-last) < 40
|
||||
value.repeatBoost = repeat
|
||||
|
||||
// Update last-seen idx.
|
||||
recentBoosts[id] = idx
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Load will load given page of timeline statuses. First it
|
||||
// will prioritize fetching statuses from the sliding window
|
||||
// that is the timeline cache of latest statuses, else it will
|
||||
// fall back to loading from the database using callback funcs.
|
||||
// The returned string values are the low / high status ID
|
||||
// paging values, used in calculating next / prev page links.
|
||||
func (t *StatusTimeline) Load(
|
||||
ctx context.Context,
|
||||
page *paging.Page,
|
||||
|
||||
// loadPage should load the timeline of given page for cache hydration.
|
||||
loadPage func(page *paging.Page) (statuses []*gtsmodel.Status, err error),
|
||||
|
||||
// loadIDs should load status models with given IDs, this is used
|
||||
// to load status models of already cached entries in the timeline.
|
||||
loadIDs func(ids []string) (statuses []*gtsmodel.Status, err error),
|
||||
|
||||
// filter performs filtering of returned statuses.
|
||||
filter func(each *gtsmodel.Status) (delete bool),
|
||||
|
||||
// prepareAPI should prepare internal status model to frontend API model.
|
||||
prepareAPI func(status *gtsmodel.Status) (apiStatus *apimodel.Status, err error),
|
||||
) (
|
||||
[]*apimodel.Status,
|
||||
string, // lo
|
||||
string, // hi
|
||||
error,
|
||||
) {
|
||||
var err error
|
||||
|
||||
// Get paging details.
|
||||
lo := page.Min.Value
|
||||
hi := page.Max.Value
|
||||
limit := page.Limit
|
||||
order := page.Order()
|
||||
dir := toDirection(order)
|
||||
|
||||
// Use a copy of current page so
|
||||
// we can repeatedly update it.
|
||||
nextPg := new(paging.Page)
|
||||
*nextPg = *page
|
||||
nextPg.Min.Value = lo
|
||||
nextPg.Max.Value = hi
|
||||
|
||||
// Interstitial meta objects.
|
||||
var metas []*StatusMeta
|
||||
|
||||
// Returned frontend API statuses.
|
||||
var apiStatuses []*apimodel.Status
|
||||
|
||||
// TODO: we can remove this nil
|
||||
// check when we've updated all
|
||||
// our timeline endpoints to have
|
||||
// streamed timeline caches.
|
||||
if t != nil {
|
||||
|
||||
// Ensure timeline has been preloaded.
|
||||
_, err = t.Preload(loadPage, filter)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
// First we attempt to load status
|
||||
// metadata entries from the timeline
|
||||
// cache, up to given limit.
|
||||
metas = t.cache.Select(
|
||||
util.PtrIf(lo),
|
||||
util.PtrIf(hi),
|
||||
util.PtrIf(limit),
|
||||
dir,
|
||||
)
|
||||
|
||||
if len(metas) > 0 {
|
||||
// Before we can do any filtering, we need
|
||||
// to load status models for cached entries.
|
||||
err = loadStatuses(metas, loadIDs)
|
||||
if err != nil {
|
||||
return nil, "", "", gtserror.Newf("error loading statuses: %w", err)
|
||||
}
|
||||
|
||||
// Set returned lo, hi values.
|
||||
lo = metas[len(metas)-1].ID
|
||||
hi = metas[0].ID
|
||||
|
||||
// Allocate slice of expected required API models.
|
||||
apiStatuses = make([]*apimodel.Status, 0, len(metas))
|
||||
|
||||
// Prepare frontend API models for
|
||||
// the cached statuses. For now this
|
||||
// also does its own extra filtering.
|
||||
apiStatuses = prepareStatuses(ctx,
|
||||
metas,
|
||||
prepareAPI,
|
||||
apiStatuses,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If no cached timeline statuses
|
||||
// were found for page, we need to
|
||||
// call through to the database.
|
||||
if len(apiStatuses) == 0 {
|
||||
|
||||
// Pass through to main timeline db load function.
|
||||
apiStatuses, lo, hi, err = loadStatusTimeline(ctx,
|
||||
nextPg,
|
||||
metas,
|
||||
apiStatuses,
|
||||
loadPage,
|
||||
filter,
|
||||
prepareAPI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
if order.Ascending() {
|
||||
// The caller always expects the statuses
|
||||
// to be returned in DESC order, but we
|
||||
// build the status slice in paging order.
|
||||
// If paging ASC, we need to reverse the
|
||||
// returned statuses and paging values.
|
||||
slices.Reverse(apiStatuses)
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
|
||||
return apiStatuses, lo, hi, nil
|
||||
}
|
||||
|
||||
// loadStatusTimeline encapsulates the logic of iteratively
|
||||
// attempting to load a status timeline page from the database,
|
||||
// that is in the form of given callback functions. these will
|
||||
// then be prepared to frontend API models for return.
|
||||
//
|
||||
// in time it may make sense to move this logic
|
||||
// into the StatusTimeline{}.Load() function.
|
||||
func loadStatusTimeline(
|
||||
ctx context.Context,
|
||||
nextPg *paging.Page,
|
||||
metas []*StatusMeta,
|
||||
apiStatuses []*apimodel.Status,
|
||||
loadPage func(page *paging.Page) (statuses []*gtsmodel.Status, err error),
|
||||
filter func(each *gtsmodel.Status) (delete bool),
|
||||
prepareAPI func(status *gtsmodel.Status) (apiStatus *apimodel.Status, err error),
|
||||
) (
|
||||
[]*apimodel.Status,
|
||||
string, // lo
|
||||
string, // hi
|
||||
error,
|
||||
) {
|
||||
if loadPage == nil {
|
||||
panic("nil load page func")
|
||||
}
|
||||
|
||||
// Lowest and highest ID
|
||||
// vals of loaded statuses.
|
||||
var lo, hi string
|
||||
|
||||
// Extract paging params.
|
||||
order := nextPg.Order()
|
||||
limit := nextPg.Limit
|
||||
|
||||
// Load a little more than
|
||||
// limit to reduce db calls.
|
||||
nextPg.Limit += 10
|
||||
|
||||
// Ensure we have a slice of meta objects to
|
||||
// use in later preparation of the API models.
|
||||
metas = xslices.GrowJust(metas[:0], nextPg.Limit)
|
||||
|
||||
// Ensure we have a slice of required frontend API models.
|
||||
apiStatuses = xslices.GrowJust(apiStatuses[:0], nextPg.Limit)
|
||||
|
||||
// Perform maximum of 5 load
|
||||
// attempts fetching statuses.
|
||||
for i := 0; i < 5; i++ {
|
||||
|
||||
// Load next timeline statuses.
|
||||
statuses, err := loadPage(nextPg)
|
||||
if err != nil {
|
||||
return nil, "", "", gtserror.Newf("error loading timeline: %w", err)
|
||||
}
|
||||
|
||||
// No more statuses from
|
||||
// load function = at end.
|
||||
if len(statuses) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if hi == "" {
|
||||
// Set hi returned paging
|
||||
// value if not already set.
|
||||
hi = statuses[0].ID
|
||||
}
|
||||
|
||||
// Update nextPg cursor parameter for next database query.
|
||||
nextPageParams(nextPg, statuses[len(statuses)-1].ID, order)
|
||||
|
||||
// Perform any filtering on newly loaded statuses.
|
||||
statuses = doStatusFilter(statuses, filter)
|
||||
|
||||
// After filtering no more
|
||||
// statuses remain, retry.
|
||||
if len(statuses) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to our interstitial meta type.
|
||||
metas = toStatusMeta(metas[:0], statuses)
|
||||
|
||||
// Prepare frontend API models for
|
||||
// the loaded statuses. For now this
|
||||
// also does its own extra filtering.
|
||||
apiStatuses = prepareStatuses(ctx,
|
||||
metas,
|
||||
prepareAPI,
|
||||
apiStatuses,
|
||||
limit,
|
||||
)
|
||||
|
||||
// If we have anything, return
|
||||
// here. Even if below limit.
|
||||
if len(apiStatuses) > 0 {
|
||||
|
||||
// Set returned lo status paging value.
|
||||
lo = apiStatuses[len(apiStatuses)-1].ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return apiStatuses, lo, hi, nil
|
||||
}
|
||||
|
||||
// InsertOne allows you to insert a single status into the timeline, with optional prepared API model.
|
||||
// The return value indicates whether status should be skipped from streams, e.g. if already boosted recently.
|
||||
func (t *StatusTimeline) InsertOne(status *gtsmodel.Status, prepared *apimodel.Status) (skip bool) {
|
||||
|
||||
// If timeline no preloaded, i.e.
|
||||
// no-one using it, don't insert.
|
||||
if !t.preloader.Check() {
|
||||
return false
|
||||
}
|
||||
|
||||
if status.BoostOfID != "" {
|
||||
// Check through top $repeatBoostDepth number of items.
|
||||
for i, value := range t.cache.RangeUnsafe(structr.Desc) {
|
||||
if i >= repeatBoostDepth {
|
||||
break
|
||||
}
|
||||
|
||||
// We don't care about values that have
|
||||
// already been hidden as repeat boosts.
|
||||
if value.repeatBoost {
|
||||
continue
|
||||
}
|
||||
|
||||
// If inserted status has already been boosted, or original was posted
|
||||
// within last $repeatBoostDepth, we indicate it as a repeated boost.
|
||||
if value.ID == status.BoostOfID || value.BoostOfID == status.BoostOfID {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new timeline status.
|
||||
t.cache.Insert(&StatusMeta{
|
||||
ID: status.ID,
|
||||
AccountID: status.AccountID,
|
||||
BoostOfID: status.BoostOfID,
|
||||
BoostOfAccountID: status.BoostOfAccountID,
|
||||
repeatBoost: skip,
|
||||
loaded: nil,
|
||||
prepared: prepared,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RemoveByStatusID removes all cached timeline entries pertaining to
|
||||
// status ID, including those that may be a boost of the given status.
|
||||
func (t *StatusTimeline) RemoveByStatusIDs(statusIDs ...string) {
|
||||
keys := make([]structr.Key, len(statusIDs))
|
||||
|
||||
// Nil check indices outside loops.
|
||||
if t.idx_ID == nil ||
|
||||
t.idx_BoostOfID == nil {
|
||||
panic("indices are nil")
|
||||
}
|
||||
|
||||
// Convert statusIDs to index keys.
|
||||
for i, id := range statusIDs {
|
||||
keys[i] = t.idx_ID.Key(id)
|
||||
}
|
||||
|
||||
// Invalidate all cached entries with IDs.
|
||||
t.cache.Invalidate(t.idx_ID, keys...)
|
||||
|
||||
// Convert statusIDs to index keys.
|
||||
for i, id := range statusIDs {
|
||||
keys[i] = t.idx_BoostOfID.Key(id)
|
||||
}
|
||||
|
||||
// Invalidate all cached entries as boost of IDs.
|
||||
t.cache.Invalidate(t.idx_BoostOfID, keys...)
|
||||
}
|
||||
|
||||
// RemoveByAccountID removes all cached timeline entries authored by
|
||||
// account ID, including those that may be boosted by account ID.
|
||||
func (t *StatusTimeline) RemoveByAccountIDs(accountIDs ...string) {
|
||||
keys := make([]structr.Key, len(accountIDs))
|
||||
|
||||
// Nil check indices outside loops.
|
||||
if t.idx_AccountID == nil ||
|
||||
t.idx_BoostOfAccountID == nil {
|
||||
panic("indices are nil")
|
||||
}
|
||||
|
||||
// Convert accountIDs to index keys.
|
||||
for i, id := range accountIDs {
|
||||
keys[i] = t.idx_AccountID.Key(id)
|
||||
}
|
||||
|
||||
// Invalidate all cached entries as by IDs.
|
||||
t.cache.Invalidate(t.idx_AccountID, keys...)
|
||||
|
||||
// Convert accountIDs to index keys.
|
||||
for i, id := range accountIDs {
|
||||
keys[i] = t.idx_BoostOfAccountID.Key(id)
|
||||
}
|
||||
|
||||
// Invalidate all cached entries as boosted by IDs.
|
||||
t.cache.Invalidate(t.idx_BoostOfAccountID, keys...)
|
||||
}
|
||||
|
||||
// UnprepareByStatusIDs removes cached frontend API models for all cached
|
||||
// timeline entries pertaining to status ID, including boosts of given status.
|
||||
func (t *StatusTimeline) UnprepareByStatusIDs(statusIDs ...string) {
|
||||
keys := make([]structr.Key, len(statusIDs))
|
||||
|
||||
// Nil check indices outside loops.
|
||||
if t.idx_ID == nil ||
|
||||
t.idx_BoostOfID == nil {
|
||||
panic("indices are nil")
|
||||
}
|
||||
|
||||
// Convert statusIDs to index keys.
|
||||
for i, id := range statusIDs {
|
||||
keys[i] = t.idx_ID.Key(id)
|
||||
}
|
||||
|
||||
// Unprepare all statuses stored under StatusMeta.ID.
|
||||
for meta := range t.cache.RangeKeysUnsafe(t.idx_ID, keys...) {
|
||||
meta.prepared = nil
|
||||
}
|
||||
|
||||
// Convert statusIDs to index keys.
|
||||
for i, id := range statusIDs {
|
||||
keys[i] = t.idx_BoostOfID.Key(id)
|
||||
}
|
||||
|
||||
// Unprepare all statuses stored under StatusMeta.BoostOfID.
|
||||
for meta := range t.cache.RangeKeysUnsafe(t.idx_BoostOfID, keys...) {
|
||||
meta.prepared = nil
|
||||
}
|
||||
}
|
||||
|
||||
// UnprepareByAccountIDs removes cached frontend API models for all cached
|
||||
// timeline entries authored by account ID, including boosts by account ID.
|
||||
func (t *StatusTimeline) UnprepareByAccountIDs(accountIDs ...string) {
|
||||
keys := make([]structr.Key, len(accountIDs))
|
||||
|
||||
// Nil check indices outside loops.
|
||||
if t.idx_AccountID == nil ||
|
||||
t.idx_BoostOfAccountID == nil {
|
||||
panic("indices are nil")
|
||||
}
|
||||
|
||||
// Convert accountIDs to index keys.
|
||||
for i, id := range accountIDs {
|
||||
keys[i] = t.idx_AccountID.Key(id)
|
||||
}
|
||||
|
||||
// Unprepare all statuses stored under StatusMeta.AccountID.
|
||||
for meta := range t.cache.RangeKeysUnsafe(t.idx_AccountID, keys...) {
|
||||
meta.prepared = nil
|
||||
}
|
||||
|
||||
// Convert accountIDs to index keys.
|
||||
for i, id := range accountIDs {
|
||||
keys[i] = t.idx_BoostOfAccountID.Key(id)
|
||||
}
|
||||
|
||||
// Unprepare all statuses stored under StatusMeta.BoostOfAccountID.
|
||||
for meta := range t.cache.RangeKeysUnsafe(t.idx_BoostOfAccountID, keys...) {
|
||||
meta.prepared = nil
|
||||
}
|
||||
}
|
||||
|
||||
// UnprepareAll removes cached frontend API
|
||||
// models for all cached timeline entries.
|
||||
func (t *StatusTimeline) UnprepareAll() {
|
||||
for _, value := range t.cache.RangeUnsafe(structr.Asc) {
|
||||
value.prepared = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trim will ensure that receiving timeline is less than or
|
||||
// equal in length to the given threshold percentage of the
|
||||
// timeline's preconfigured maximum capacity. This will always
|
||||
// trim from the bottom-up to prioritize streamed inserts.
|
||||
func (t *StatusTimeline) Trim() { t.cache.Trim(t.cut, structr.Asc) }
|
||||
|
||||
// Clear will mark the entire timeline as requiring preload,
|
||||
// which will trigger a clear and reload of the entire thing.
|
||||
func (t *StatusTimeline) Clear() { t.preloader.Clear() }
|
||||
|
||||
// prepareStatuses takes a slice of cached (or, freshly loaded!) StatusMeta{}
|
||||
// models, and use given function to return prepared frontend API models.
|
||||
func prepareStatuses(
|
||||
ctx context.Context,
|
||||
meta []*StatusMeta,
|
||||
prepareAPI func(*gtsmodel.Status) (*apimodel.Status, error),
|
||||
apiStatuses []*apimodel.Status,
|
||||
limit int,
|
||||
) []*apimodel.Status {
|
||||
switch { //nolint:gocritic
|
||||
case prepareAPI == nil:
|
||||
panic("nil prepare fn")
|
||||
}
|
||||
|
||||
// Iterate the given StatusMeta objects for pre-prepared
|
||||
// frontend models, otherwise attempting to prepare them.
|
||||
for _, meta := range meta {
|
||||
|
||||
// Check if we have prepared enough
|
||||
// API statuses for caller to return.
|
||||
if len(apiStatuses) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
if meta.loaded == nil {
|
||||
// We failed loading this
|
||||
// status, skip preparing.
|
||||
continue
|
||||
}
|
||||
|
||||
if meta.repeatBoost {
|
||||
// This is a repeat boost in
|
||||
// short timespan, skip it.
|
||||
continue
|
||||
}
|
||||
|
||||
if meta.prepared == nil {
|
||||
var err error
|
||||
|
||||
// Prepare the provided status to frontend.
|
||||
meta.prepared, err = prepareAPI(meta.loaded)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error preparing status %s: %v", meta.loaded.URI, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Append to return slice.
|
||||
if meta.prepared != nil {
|
||||
apiStatuses = append(apiStatuses, meta.prepared)
|
||||
}
|
||||
}
|
||||
|
||||
return apiStatuses
|
||||
}
|
||||
|
||||
// loadStatuses loads statuses using provided callback
|
||||
// for the statuses in meta slice that aren't loaded.
|
||||
// the amount very much depends on whether meta objects
|
||||
// are yet-to-be-cached (i.e. newly loaded, with status),
|
||||
// or are from the timeline cache (unloaded status).
|
||||
func loadStatuses(
|
||||
metas []*StatusMeta,
|
||||
loadIDs func([]string) ([]*gtsmodel.Status, error),
|
||||
) error {
|
||||
|
||||
// Determine which of our passed status
|
||||
// meta objects still need statuses loading.
|
||||
toLoadIDs := make([]string, len(metas))
|
||||
loadedMap := make(map[string]*StatusMeta, len(metas))
|
||||
for i, meta := range metas {
|
||||
if meta.loaded == nil {
|
||||
toLoadIDs[i] = meta.ID
|
||||
loadedMap[meta.ID] = meta
|
||||
}
|
||||
}
|
||||
|
||||
// Load statuses with given IDs.
|
||||
loaded, err := loadIDs(toLoadIDs)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error loading statuses: %w", err)
|
||||
}
|
||||
|
||||
// Update returned StatusMeta objects
|
||||
// with newly loaded statuses by IDs.
|
||||
for i := range loaded {
|
||||
status := loaded[i]
|
||||
meta := loadedMap[status.ID]
|
||||
meta.loaded = status
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toStatusMeta converts a slice of database model statuses
|
||||
// into our cache wrapper type, a slice of []StatusMeta{}.
|
||||
func toStatusMeta(in []*StatusMeta, statuses []*gtsmodel.Status) []*StatusMeta {
|
||||
return xslices.Gather(in, statuses, func(s *gtsmodel.Status) *StatusMeta {
|
||||
return &StatusMeta{
|
||||
ID: s.ID,
|
||||
AccountID: s.AccountID,
|
||||
BoostOfID: s.BoostOfID,
|
||||
BoostOfAccountID: s.BoostOfAccountID,
|
||||
loaded: s,
|
||||
prepared: nil,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// doStatusFilter performs given filter function on provided statuses,
|
||||
func doStatusFilter(statuses []*gtsmodel.Status, filter func(*gtsmodel.Status) bool) []*gtsmodel.Status {
|
||||
|
||||
// Check for provided
|
||||
// filter function.
|
||||
if filter == nil {
|
||||
return statuses
|
||||
}
|
||||
|
||||
// Filter the provided input statuses.
|
||||
return slices.DeleteFunc(statuses, filter)
|
||||
}
|
||||
198
internal/cache/timeline/status_map.go
vendored
Normal file
198
internal/cache/timeline/status_map.go
vendored
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// 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 (
|
||||
"maps"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StatusTimelines is a concurrency safe map of StatusTimeline{}
|
||||
// objects, optimizing *very heavily* for reads over writes.
|
||||
type StatusTimelines struct {
|
||||
ptr atomic.Pointer[map[string]*StatusTimeline] // ronly except by CAS
|
||||
cap int
|
||||
}
|
||||
|
||||
// Init stores the given argument(s) such that any created StatusTimeline{}
|
||||
// objects by MustGet() will initialize them with the given arguments.
|
||||
func (t *StatusTimelines) Init(cap int) { t.cap = cap }
|
||||
|
||||
// MustGet will attempt to fetch StatusTimeline{} stored under key, else creating one.
|
||||
func (t *StatusTimelines) MustGet(key string) *StatusTimeline {
|
||||
var tt *StatusTimeline
|
||||
|
||||
for {
|
||||
// Load current ptr.
|
||||
cur := t.ptr.Load()
|
||||
|
||||
// Get timeline map to work on.
|
||||
var m map[string]*StatusTimeline
|
||||
|
||||
if cur != nil {
|
||||
// Look for existing
|
||||
// timeline in cache.
|
||||
tt = (*cur)[key]
|
||||
if tt != nil {
|
||||
return tt
|
||||
}
|
||||
|
||||
// Get clone of current
|
||||
// before modifications.
|
||||
m = maps.Clone(*cur)
|
||||
} else {
|
||||
// Allocate new timeline map for below.
|
||||
m = make(map[string]*StatusTimeline)
|
||||
}
|
||||
|
||||
if tt == nil {
|
||||
// Allocate new timeline.
|
||||
tt = new(StatusTimeline)
|
||||
tt.Init(t.cap)
|
||||
}
|
||||
|
||||
// Store timeline
|
||||
// in new map.
|
||||
m[key] = tt
|
||||
|
||||
// Attempt to update the map ptr.
|
||||
if !t.ptr.CompareAndSwap(cur, &m) {
|
||||
|
||||
// We failed the
|
||||
// CAS, reloop.
|
||||
continue
|
||||
}
|
||||
|
||||
// Successfully inserted
|
||||
// new timeline model.
|
||||
return tt
|
||||
}
|
||||
}
|
||||
|
||||
// Delete will delete the stored StatusTimeline{} under key, if any.
|
||||
func (t *StatusTimelines) Delete(key string) {
|
||||
for {
|
||||
// Load current ptr.
|
||||
cur := t.ptr.Load()
|
||||
|
||||
// Check for empty map / not in map.
|
||||
if cur == nil || (*cur)[key] == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get clone of current
|
||||
// before modifications.
|
||||
m := maps.Clone(*cur)
|
||||
|
||||
// Delete ID.
|
||||
delete(m, key)
|
||||
|
||||
// Attempt to update the map ptr.
|
||||
if !t.ptr.CompareAndSwap(cur, &m) {
|
||||
|
||||
// We failed the
|
||||
// CAS, reloop.
|
||||
continue
|
||||
}
|
||||
|
||||
// Successfully
|
||||
// deleted ID.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveByStatusIDs calls RemoveByStatusIDs() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) RemoveByStatusIDs(statusIDs ...string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.RemoveByStatusIDs(statusIDs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveByAccountIDs calls RemoveByAccountIDs() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) RemoveByAccountIDs(accountIDs ...string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.RemoveByAccountIDs(accountIDs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnprepareByStatusIDs calls UnprepareByStatusIDs() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) UnprepareByStatusIDs(statusIDs ...string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.UnprepareByStatusIDs(statusIDs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnprepareByAccountIDs calls UnprepareByAccountIDs() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) UnprepareByAccountIDs(accountIDs ...string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.UnprepareByAccountIDs(accountIDs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unprepare attempts to call UnprepareAll() for StatusTimeline{} under key.
|
||||
func (t *StatusTimelines) Unprepare(key string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
if tt := (*p)[key]; tt != nil {
|
||||
tt.UnprepareAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnprepareAll calls UnprepareAll() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) UnprepareAll() {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.UnprepareAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trim calls Trim() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) Trim() {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear attempts to call Clear() for StatusTimeline{} under key.
|
||||
func (t *StatusTimelines) Clear(key string) {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
if tt := (*p)[key]; tt != nil {
|
||||
tt.Clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearAll calls Clear() for each of the stored StatusTimeline{}s.
|
||||
func (t *StatusTimelines) ClearAll() {
|
||||
if p := t.ptr.Load(); p != nil {
|
||||
for _, tt := range *p {
|
||||
tt.Clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
361
internal/cache/timeline/status_test.go
vendored
Normal file
361
internal/cache/timeline/status_test.go
vendored
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// 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 (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/gruf/go-structr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
var testStatusMeta = []*StatusMeta{
|
||||
{
|
||||
ID: "06B19VYTHEG01F3YW13RQE0QM8",
|
||||
AccountID: "06B1A61MZEBBVDSNPRJAA8F2C4",
|
||||
BoostOfID: "06B1A5KQWGQ1ABM3FA7TDX1PK8",
|
||||
BoostOfAccountID: "06B1A6707818050PCK8SJAEC6G",
|
||||
},
|
||||
{
|
||||
ID: "06B19VYTJFT0KDWT5C1CPY0XNC",
|
||||
AccountID: "06B1A61MZN3ZQPZVNGEFBNYBJW",
|
||||
BoostOfID: "06B1A5KQWSGFN4NNRV34KV5S9R",
|
||||
BoostOfAccountID: "06B1A6707HY8RAXG7JPCWR7XD4",
|
||||
},
|
||||
{
|
||||
ID: "06B19VYTJ6WZQPRVNJHPEZH04W",
|
||||
AccountID: "06B1A61MZY7E0YB6G01VJX8ERR",
|
||||
BoostOfID: "06B1A5KQX5NPGSYGH8NC7HR1GR",
|
||||
BoostOfAccountID: "06B1A6707XCSAF0MVCGGYF9160",
|
||||
},
|
||||
{
|
||||
ID: "06B19VYTJPKGG8JYCR1ENAV7KC",
|
||||
AccountID: "06B1A61N07K1GC35PJ3CZ4M020",
|
||||
BoostOfID: "06B1A5KQXG6ZCWE1R7C7KR7RYW",
|
||||
BoostOfAccountID: "06B1A67084W6SB6P6HJB7K5DSG",
|
||||
},
|
||||
{
|
||||
ID: "06B19VYTHRR8S35QXC5A6VE2YW",
|
||||
AccountID: "06B1A61N0P1TGQDVKANNG4AKP4",
|
||||
BoostOfID: "06B1A5KQY3K839Z6S5HHAJKSWW",
|
||||
BoostOfAccountID: "06B1A6708SPJC3X3ZG3SGG8BN8",
|
||||
},
|
||||
}
|
||||
|
||||
func TestStatusTimelineUnprepare(t *testing.T) {
|
||||
var tt StatusTimeline
|
||||
tt.Init(1000)
|
||||
|
||||
// Clone the input test status data.
|
||||
data := slices.Clone(testStatusMeta)
|
||||
|
||||
// Bodge some 'prepared'
|
||||
// models on test data.
|
||||
for _, meta := range data {
|
||||
meta.prepared = &apimodel.Status{}
|
||||
}
|
||||
|
||||
// Insert test data into timeline.
|
||||
_ = tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Unprepare this status with ID.
|
||||
tt.UnprepareByStatusIDs(meta.ID)
|
||||
|
||||
// Check the item is unprepared.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value.prepared)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Unprepare this status with boost ID.
|
||||
tt.UnprepareByStatusIDs(meta.BoostOfID)
|
||||
|
||||
// Check the item is unprepared.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value.prepared)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Unprepare this status with account ID.
|
||||
tt.UnprepareByAccountIDs(meta.AccountID)
|
||||
|
||||
// Check the item is unprepared.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value.prepared)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Unprepare this status with boost account ID.
|
||||
tt.UnprepareByAccountIDs(meta.BoostOfAccountID)
|
||||
|
||||
// Check the item is unprepared.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value.prepared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTimelineRemove(t *testing.T) {
|
||||
var tt StatusTimeline
|
||||
tt.Init(1000)
|
||||
|
||||
// Clone the input test status data.
|
||||
data := slices.Clone(testStatusMeta)
|
||||
|
||||
// Insert test data into timeline.
|
||||
_ = tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Remove this status with ID.
|
||||
tt.RemoveByStatusIDs(meta.ID)
|
||||
|
||||
// Check the item is now gone.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Remove this status with boost ID.
|
||||
tt.RemoveByStatusIDs(meta.BoostOfID)
|
||||
|
||||
// Check the item is now gone.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Remove this status with account ID.
|
||||
tt.RemoveByAccountIDs(meta.AccountID)
|
||||
|
||||
// Check the item is now gone.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
// Clear and reinsert.
|
||||
tt.cache.Clear()
|
||||
tt.cache.Insert(data...)
|
||||
|
||||
for _, meta := range data {
|
||||
// Remove this status with boost account ID.
|
||||
tt.RemoveByAccountIDs(meta.BoostOfAccountID)
|
||||
|
||||
// Check the item is now gone.
|
||||
value := getStatusByID(&tt, meta.ID)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTimelineInserts(t *testing.T) {
|
||||
var tt StatusTimeline
|
||||
tt.Init(1000)
|
||||
|
||||
// Clone the input test status data.
|
||||
data := slices.Clone(testStatusMeta)
|
||||
|
||||
// Insert test data into timeline.
|
||||
l := tt.cache.Insert(data...)
|
||||
assert.Equal(t, len(data), l)
|
||||
|
||||
// Ensure 'min' value status
|
||||
// in the timeline is expected.
|
||||
minID := minStatusID(data)
|
||||
assert.Equal(t, minID, minStatus(&tt).ID)
|
||||
|
||||
// Ensure 'max' value status
|
||||
// in the timeline is expected.
|
||||
maxID := maxStatusID(data)
|
||||
assert.Equal(t, maxID, maxStatus(&tt).ID)
|
||||
|
||||
// Manually mark timeline as 'preloaded'.
|
||||
tt.preloader.CheckPreload(tt.preloader.Done)
|
||||
|
||||
// Specifically craft a boost of latest (i.e. max) status in timeline.
|
||||
boost := >smodel.Status{ID: "06B1A00PQWDZZH9WK9P5VND35C", BoostOfID: maxID}
|
||||
|
||||
// Insert boost into the timeline
|
||||
// checking for 'repeatBoost' notifier.
|
||||
repeatBoost := tt.InsertOne(boost, nil)
|
||||
assert.True(t, repeatBoost)
|
||||
|
||||
// This should be the new 'max'
|
||||
// and have 'repeatBoost' set.
|
||||
newMax := maxStatus(&tt)
|
||||
assert.Equal(t, boost.ID, newMax.ID)
|
||||
assert.True(t, newMax.repeatBoost)
|
||||
|
||||
// Specifically craft 2 boosts of some unseen status in the timeline.
|
||||
boost1 := >smodel.Status{ID: "06B1A121YEX02S0AY48X93JMDW", BoostOfID: "unseen"}
|
||||
boost2 := >smodel.Status{ID: "06B1A12TG2NTJC9P270EQXS08M", BoostOfID: "unseen"}
|
||||
|
||||
// Insert boosts into the timeline, ensuring
|
||||
// first is not 'repeat', but second one is.
|
||||
repeatBoost1 := tt.InsertOne(boost1, nil)
|
||||
repeatBoost2 := tt.InsertOne(boost2, nil)
|
||||
assert.False(t, repeatBoost1)
|
||||
assert.True(t, repeatBoost2)
|
||||
}
|
||||
|
||||
func TestStatusTimelineTrim(t *testing.T) {
|
||||
var tt StatusTimeline
|
||||
tt.Init(1000)
|
||||
|
||||
// Clone the input test status data.
|
||||
data := slices.Clone(testStatusMeta)
|
||||
|
||||
// Insert test data into timeline.
|
||||
_ = tt.cache.Insert(data...)
|
||||
|
||||
// From here it'll be easier to have DESC sorted
|
||||
// test data for reslicing and checking against.
|
||||
slices.SortFunc(data, func(a, b *StatusMeta) int {
|
||||
const k = +1
|
||||
switch {
|
||||
case a.ID < b.ID:
|
||||
return +k
|
||||
case b.ID < a.ID:
|
||||
return -k
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
// Set manual cutoff for trim.
|
||||
tt.cut = len(data) - 1
|
||||
|
||||
// Perform trim.
|
||||
tt.Trim()
|
||||
|
||||
// The post trim length should be tt.cut
|
||||
assert.Equal(t, tt.cut, tt.cache.Len())
|
||||
|
||||
// It specifically should have removed
|
||||
// the oldest (i.e. min) status element.
|
||||
minID := data[len(data)-1].ID
|
||||
assert.NotEqual(t, minID, minStatus(&tt).ID)
|
||||
assert.False(t, containsStatusID(&tt, minID))
|
||||
|
||||
// Drop trimmed status.
|
||||
data = data[:len(data)-1]
|
||||
|
||||
// Set smaller cutoff for trim.
|
||||
tt.cut = len(data) - 2
|
||||
|
||||
// Perform trim.
|
||||
tt.Trim()
|
||||
|
||||
// The post trim length should be tt.cut
|
||||
assert.Equal(t, tt.cut, tt.cache.Len())
|
||||
|
||||
// It specifically should have removed
|
||||
// the oldest 2 (i.e. min) status elements.
|
||||
minID1 := data[len(data)-1].ID
|
||||
minID2 := data[len(data)-2].ID
|
||||
assert.NotEqual(t, minID1, minStatus(&tt).ID)
|
||||
assert.NotEqual(t, minID2, minStatus(&tt).ID)
|
||||
assert.False(t, containsStatusID(&tt, minID1))
|
||||
assert.False(t, containsStatusID(&tt, minID2))
|
||||
|
||||
// Trim at desired length
|
||||
// should cause no change.
|
||||
before := tt.cache.Len()
|
||||
tt.Trim()
|
||||
assert.Equal(t, before, tt.cache.Len())
|
||||
}
|
||||
|
||||
// containsStatusID returns whether timeline contains a status with ID.
|
||||
func containsStatusID(t *StatusTimeline, id string) bool {
|
||||
return getStatusByID(t, id) != nil
|
||||
}
|
||||
|
||||
// getStatusByID attempts to fetch status with given ID from timeline.
|
||||
func getStatusByID(t *StatusTimeline, id string) *StatusMeta {
|
||||
for _, value := range t.cache.Range(structr.Desc) {
|
||||
if value.ID == id {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxStatus returns the newest (i.e. highest value ID) status in timeline.
|
||||
func maxStatus(t *StatusTimeline) *StatusMeta {
|
||||
var meta *StatusMeta
|
||||
for _, value := range t.cache.Range(structr.Desc) {
|
||||
meta = value
|
||||
break
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// minStatus returns the oldest (i.e. lowest value ID) status in timeline.
|
||||
func minStatus(t *StatusTimeline) *StatusMeta {
|
||||
var meta *StatusMeta
|
||||
for _, value := range t.cache.Range(structr.Asc) {
|
||||
meta = value
|
||||
break
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// minStatusID returns the oldest (i.e. lowest value ID) status in metas.
|
||||
func minStatusID(metas []*StatusMeta) string {
|
||||
var min string
|
||||
min = metas[0].ID
|
||||
for i := 1; i < len(metas); i++ {
|
||||
if metas[i].ID < min {
|
||||
min = metas[i].ID
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// maxStatusID returns the newest (i.e. highest value ID) status in metas.
|
||||
func maxStatusID(metas []*StatusMeta) string {
|
||||
var max string
|
||||
max = metas[0].ID
|
||||
for i := 1; i < len(metas); i++ {
|
||||
if metas[i].ID > max {
|
||||
max = metas[i].ID
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
59
internal/cache/timeline/timeline.go
vendored
Normal file
59
internal/cache/timeline/timeline.go
vendored
Normal 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 timeline
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-structr"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// plus1hULID returns a ULID for now+1h.
|
||||
func plus1hULID() string {
|
||||
t := time.Now().Add(time.Hour)
|
||||
return id.NewULIDFromTime(t)
|
||||
}
|
||||
|
||||
// nextPageParams gets the next set of paging
|
||||
// parameters to use based on the current set,
|
||||
// and the next set of lo / hi values. This will
|
||||
// correctly handle making sure that, depending
|
||||
// on the paging order, the cursor value gets
|
||||
// updated while maintaining the boundary value.
|
||||
func nextPageParams(
|
||||
page *paging.Page,
|
||||
lastIdx string,
|
||||
order paging.Order,
|
||||
) {
|
||||
if order.Ascending() {
|
||||
page.Min.Value = lastIdx
|
||||
} else /* i.e. descending */ { //nolint:revive
|
||||
page.Max.Value = lastIdx
|
||||
}
|
||||
}
|
||||
|
||||
// toDirection converts page order to timeline direction.
|
||||
func toDirection(order paging.Order) structr.Direction {
|
||||
if order.Ascending() {
|
||||
return structr.Asc
|
||||
} else /* i.e. descending */ { //nolint:revive
|
||||
return structr.Desc
|
||||
}
|
||||
}
|
||||
84
internal/cache/wrappers.go
vendored
84
internal/cache/wrappers.go
vendored
|
|
@ -27,19 +27,19 @@ import (
|
|||
// SliceCache wraps a simple.Cache to provide simple loader-callback
|
||||
// functions for fetching + caching slices of objects (e.g. IDs).
|
||||
type SliceCache[T any] struct {
|
||||
cache simple.Cache[string, []T]
|
||||
simple.Cache[string, []T]
|
||||
}
|
||||
|
||||
// Init initializes the cache with given length + capacity.
|
||||
func (c *SliceCache[T]) Init(len, cap int) {
|
||||
c.cache = simple.Cache[string, []T]{}
|
||||
c.cache.Init(len, cap)
|
||||
c.Cache = simple.Cache[string, []T]{}
|
||||
c.Cache.Init(len, cap)
|
||||
}
|
||||
|
||||
// Load will attempt to load an existing slice from cache for key, else calling load function and caching the result.
|
||||
func (c *SliceCache[T]) Load(key string, load func() ([]T, error)) ([]T, error) {
|
||||
// Look for cached values.
|
||||
data, ok := c.cache.Get(key)
|
||||
data, ok := c.Cache.Get(key)
|
||||
|
||||
if !ok {
|
||||
var err error
|
||||
|
|
@ -51,7 +51,7 @@ func (c *SliceCache[T]) Load(key string, load func() ([]T, error)) ([]T, error)
|
|||
}
|
||||
|
||||
// Store the data.
|
||||
c.cache.Set(key, data)
|
||||
c.Cache.Set(key, data)
|
||||
}
|
||||
|
||||
// Return data clone for safety.
|
||||
|
|
@ -60,27 +60,7 @@ func (c *SliceCache[T]) Load(key string, load func() ([]T, error)) ([]T, error)
|
|||
|
||||
// Invalidate: see simple.Cache{}.InvalidateAll().
|
||||
func (c *SliceCache[T]) Invalidate(keys ...string) {
|
||||
_ = c.cache.InvalidateAll(keys...)
|
||||
}
|
||||
|
||||
// Trim: see simple.Cache{}.Trim().
|
||||
func (c *SliceCache[T]) Trim(perc float64) {
|
||||
c.cache.Trim(perc)
|
||||
}
|
||||
|
||||
// Clear: see simple.Cache{}.Clear().
|
||||
func (c *SliceCache[T]) Clear() {
|
||||
c.cache.Clear()
|
||||
}
|
||||
|
||||
// Len: see simple.Cache{}.Len().
|
||||
func (c *SliceCache[T]) Len() int {
|
||||
return c.cache.Len()
|
||||
}
|
||||
|
||||
// Cap: see simple.Cache{}.Cap().
|
||||
func (c *SliceCache[T]) Cap() int {
|
||||
return c.cache.Cap()
|
||||
_ = c.Cache.InvalidateAll(keys...)
|
||||
}
|
||||
|
||||
// StructCache wraps a structr.Cache{} to simple index caching
|
||||
|
|
@ -89,17 +69,17 @@ func (c *SliceCache[T]) Cap() int {
|
|||
// name under the main database caches struct which would reduce
|
||||
// time required to access cached values).
|
||||
type StructCache[StructType any] struct {
|
||||
cache structr.Cache[StructType]
|
||||
structr.Cache[StructType]
|
||||
index map[string]*structr.Index
|
||||
}
|
||||
|
||||
// Init initializes the cache with given structr.CacheConfig{}.
|
||||
func (c *StructCache[T]) Init(config structr.CacheConfig[T]) {
|
||||
c.index = make(map[string]*structr.Index, len(config.Indices))
|
||||
c.cache = structr.Cache[T]{}
|
||||
c.cache.Init(config)
|
||||
c.Cache = structr.Cache[T]{}
|
||||
c.Cache.Init(config)
|
||||
for _, cfg := range config.Indices {
|
||||
c.index[cfg.Fields] = c.cache.Index(cfg.Fields)
|
||||
c.index[cfg.Fields] = c.Cache.Index(cfg.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,26 +87,21 @@ func (c *StructCache[T]) Init(config structr.CacheConfig[T]) {
|
|||
// Note: this also handles conversion of the untyped (any) keys to structr.Key{} via structr.Index{}.
|
||||
func (c *StructCache[T]) GetOne(index string, key ...any) (T, bool) {
|
||||
i := c.index[index]
|
||||
return c.cache.GetOne(i, i.Key(key...))
|
||||
return c.Cache.GetOne(i, i.Key(key...))
|
||||
}
|
||||
|
||||
// Get calls structr.Cache{}.Get(), using a cached structr.Index{} by 'index' name.
|
||||
// Note: this also handles conversion of the untyped (any) keys to structr.Key{} via structr.Index{}.
|
||||
func (c *StructCache[T]) Get(index string, keys ...[]any) []T {
|
||||
i := c.index[index]
|
||||
return c.cache.Get(i, i.Keys(keys...)...)
|
||||
}
|
||||
|
||||
// Put: see structr.Cache{}.Put().
|
||||
func (c *StructCache[T]) Put(values ...T) {
|
||||
c.cache.Put(values...)
|
||||
return c.Cache.Get(i, i.Keys(keys...)...)
|
||||
}
|
||||
|
||||
// LoadOne calls structr.Cache{}.LoadOne(), using a cached structr.Index{} by 'index' name.
|
||||
// Note: this also handles conversion of the untyped (any) keys to structr.Key{} via structr.Index{}.
|
||||
func (c *StructCache[T]) LoadOne(index string, load func() (T, error), key ...any) (T, error) {
|
||||
i := c.index[index]
|
||||
return c.cache.LoadOne(i, i.Key(key...), load)
|
||||
return c.Cache.LoadOne(i, i.Key(key...), load)
|
||||
}
|
||||
|
||||
// LoadIDs calls structr.Cache{}.Load(), using a cached structr.Index{} by 'index' name. Note: this also handles
|
||||
|
|
@ -149,7 +124,7 @@ func (c *StructCache[T]) LoadIDs(index string, ids []string, load func([]string)
|
|||
}
|
||||
|
||||
// Pass loader callback with wrapper onto main cache load function.
|
||||
return c.cache.Load(i, keys, func(uncached []structr.Key) ([]T, error) {
|
||||
return c.Cache.Load(i, keys, func(uncached []structr.Key) ([]T, error) {
|
||||
uncachedIDs := make([]string, len(uncached))
|
||||
for i := range uncached {
|
||||
uncachedIDs[i] = uncached[i].Values()[0].(string)
|
||||
|
|
@ -177,7 +152,7 @@ func (c *StructCache[T]) LoadIDs2Part(index string, id1 string, id2s []string, l
|
|||
}
|
||||
|
||||
// Pass loader callback with wrapper onto main cache load function.
|
||||
return c.cache.Load(i, keys, func(uncached []structr.Key) ([]T, error) {
|
||||
return c.Cache.Load(i, keys, func(uncached []structr.Key) ([]T, error) {
|
||||
uncachedIDs := make([]string, len(uncached))
|
||||
for i := range uncached {
|
||||
uncachedIDs[i] = uncached[i].Values()[1].(string)
|
||||
|
|
@ -186,16 +161,11 @@ func (c *StructCache[T]) LoadIDs2Part(index string, id1 string, id2s []string, l
|
|||
})
|
||||
}
|
||||
|
||||
// Store: see structr.Cache{}.Store().
|
||||
func (c *StructCache[T]) Store(value T, store func() error) error {
|
||||
return c.cache.Store(value, store)
|
||||
}
|
||||
|
||||
// Invalidate calls structr.Cache{}.Invalidate(), using a cached structr.Index{} by 'index' name.
|
||||
// Note: this also handles conversion of the untyped (any) keys to structr.Key{} via structr.Index{}.
|
||||
func (c *StructCache[T]) Invalidate(index string, key ...any) {
|
||||
i := c.index[index]
|
||||
c.cache.Invalidate(i, i.Key(key...))
|
||||
c.Cache.Invalidate(i, i.Key(key...))
|
||||
}
|
||||
|
||||
// InvalidateIDs calls structr.Cache{}.Invalidate(), using a cached structr.Index{} by 'index' name. Note: this also
|
||||
|
|
@ -218,25 +188,5 @@ func (c *StructCache[T]) InvalidateIDs(index string, ids []string) {
|
|||
}
|
||||
|
||||
// Pass to main invalidate func.
|
||||
c.cache.Invalidate(i, keys...)
|
||||
}
|
||||
|
||||
// Trim: see structr.Cache{}.Trim().
|
||||
func (c *StructCache[T]) Trim(perc float64) {
|
||||
c.cache.Trim(perc)
|
||||
}
|
||||
|
||||
// Clear: see structr.Cache{}.Clear().
|
||||
func (c *StructCache[T]) Clear() {
|
||||
c.cache.Clear()
|
||||
}
|
||||
|
||||
// Len: see structr.Cache{}.Len().
|
||||
func (c *StructCache[T]) Len() int {
|
||||
return c.cache.Len()
|
||||
}
|
||||
|
||||
// Cap: see structr.Cache{}.Cap().
|
||||
func (c *StructCache[T]) Cap() int {
|
||||
return c.cache.Cap()
|
||||
c.Cache.Invalidate(i, keys...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/admin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/cleaner"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/transport"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -74,12 +72,6 @@ func (suite *MediaTestSuite) SetupTest() {
|
|||
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testEmojis = testrig.NewTestEmojis()
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ package bundb_test
|
|||
import (
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -90,8 +88,6 @@ func (suite *BunDBStandardTestSuite) SetupTest() {
|
|||
testrig.InitTestLog()
|
||||
suite.state.Caches.Init()
|
||||
suite.db = testrig.NewTestDB(&suite.state)
|
||||
converter := typeutils.NewConverter(&suite.state)
|
||||
testrig.StartTimelines(&suite.state, visibility.NewFilter(&suite.state), converter)
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ func (l *listDB) getList(ctx context.Context, lookup string, dbQuery func(*gtsmo
|
|||
}
|
||||
|
||||
func (l *listDB) GetListsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error) {
|
||||
listIDs, err := l.getListIDsByAccountID(ctx, accountID)
|
||||
listIDs, err := l.GetListIDsByAccountID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ func (l *listDB) GetListsByAccountID(ctx context.Context, accountID string) ([]*
|
|||
}
|
||||
|
||||
func (l *listDB) CountListsByAccountID(ctx context.Context, accountID string) (int, error) {
|
||||
listIDs, err := l.getListIDsByAccountID(ctx, accountID)
|
||||
listIDs, err := l.GetListIDsByAccountID(ctx, accountID)
|
||||
return len(listIDs), err
|
||||
}
|
||||
|
||||
|
|
@ -176,10 +176,8 @@ func (l *listDB) UpdateList(ctx context.Context, list *gtsmodel.List, columns ..
|
|||
return err
|
||||
}
|
||||
|
||||
// Invalidate this entire list's timeline.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, list.ID); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
// Clear cached timeline associated with list ID.
|
||||
l.state.Caches.Timelines.List.Clear(list.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -221,10 +219,13 @@ func (l *listDB) DeleteListByID(ctx context.Context, id string) error {
|
|||
// Invalidate all related entry caches for this list.
|
||||
l.invalidateEntryCaches(ctx, []string{id}, followIDs)
|
||||
|
||||
// Delete the cached timeline of list.
|
||||
l.state.Caches.Timelines.List.Delete(id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) getListIDsByAccountID(ctx context.Context, accountID string) ([]string, error) {
|
||||
func (l *listDB) GetListIDsByAccountID(ctx context.Context, accountID string) ([]string, error) {
|
||||
return l.state.Caches.DB.ListIDs.Load("a"+accountID, func() ([]string, error) {
|
||||
var listIDs []string
|
||||
|
||||
|
|
@ -461,10 +462,8 @@ func (l *listDB) invalidateEntryCaches(ctx context.Context, listIDs, followIDs [
|
|||
"f"+listID,
|
||||
)
|
||||
|
||||
// Invalidate the timeline for the list this entry belongs to.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, listID); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
// Invalidate list timeline cache by ID.
|
||||
l.state.Caches.Timelines.List.Clear(listID)
|
||||
}
|
||||
|
||||
// Invalidate ListedID slice cache entries.
|
||||
|
|
|
|||
|
|
@ -20,15 +20,13 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
|
@ -38,346 +36,147 @@ type timelineDB struct {
|
|||
state *state.State
|
||||
}
|
||||
|
||||
func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) {
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Status, error) {
|
||||
return loadStatusTimelinePage(ctx, t.db, t.state,
|
||||
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
statusIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
)
|
||||
// Paging
|
||||
// params.
|
||||
page,
|
||||
|
||||
// As this is the home timeline, it should be
|
||||
// populated by statuses from accounts followed
|
||||
// by accountID, and posts from accountID itself.
|
||||
//
|
||||
// So, begin by seeing who accountID follows.
|
||||
// It should be a little cheaper to do this in
|
||||
// a separate query like this, rather than using
|
||||
// a join, since followIDs are cached in memory.
|
||||
follows, err := t.state.DB.GetAccountFollows(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
accountID,
|
||||
nil, // select all
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting follows for account %s: %w", accountID, err)
|
||||
}
|
||||
// The actual meat of the home-timeline query, outside
|
||||
// of any paging parameters that selects by followings.
|
||||
func(q *bun.SelectQuery) (*bun.SelectQuery, error) {
|
||||
|
||||
// To take account of exclusive lists, get all of
|
||||
// this account's lists, so we can filter out follows
|
||||
// that are in contained in exclusive lists.
|
||||
lists, err := t.state.DB.GetListsByAccountID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting lists for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Index all follow IDs that fall in exclusive lists.
|
||||
ignoreFollowIDs := make(map[string]struct{})
|
||||
for _, list := range lists {
|
||||
if !*list.Exclusive {
|
||||
// Not exclusive,
|
||||
// we don't care.
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch all follow IDs of the entries ccontained in this list.
|
||||
listFollowIDs, err := t.state.DB.GetFollowIDsInList(ctx, list.ID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting list entry follow ids: %w", err)
|
||||
}
|
||||
|
||||
// Exclusive list, index all its follow IDs.
|
||||
for _, followID := range listFollowIDs {
|
||||
ignoreFollowIDs[followID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract just the accountID from each follow,
|
||||
// ignoring follows that are in exclusive lists.
|
||||
targetAccountIDs := make([]string, 0, len(follows)+1)
|
||||
for _, f := range follows {
|
||||
_, ignore := ignoreFollowIDs[f.ID]
|
||||
if !ignore {
|
||||
targetAccountIDs = append(
|
||||
targetAccountIDs,
|
||||
f.TargetAccountID,
|
||||
// As this is the home timeline, it should be
|
||||
// populated by statuses from accounts followed
|
||||
// by accountID, and posts from accountID itself.
|
||||
//
|
||||
// So, begin by seeing who accountID follows.
|
||||
// It should be a little cheaper to do this in
|
||||
// a separate query like this, rather than using
|
||||
// a join, since followIDs are cached in memory.
|
||||
follows, err := t.state.DB.GetAccountFollows(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
accountID,
|
||||
nil, // select all
|
||||
)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting follows for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Add accountID itself as a pseudo follow so that
|
||||
// accountID can see its own posts in the timeline.
|
||||
targetAccountIDs = append(targetAccountIDs, accountID)
|
||||
// To take account of exclusive lists, get all of
|
||||
// this account's lists, so we can filter out follows
|
||||
// that are in contained in exclusive lists.
|
||||
lists, err := t.state.DB.GetListsByAccountID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting lists for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Now start building the database query.
|
||||
q := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
|
||||
// Select only IDs from table
|
||||
Column("status.id")
|
||||
// Index all follow IDs that fall in exclusive lists.
|
||||
ignoreFollowIDs := make(map[string]struct{})
|
||||
for _, list := range lists {
|
||||
if !*list.Exclusive {
|
||||
// Not exclusive,
|
||||
// we don't care.
|
||||
continue
|
||||
}
|
||||
|
||||
if maxID == "" || maxID >= id.Highest {
|
||||
const future = 24 * time.Hour
|
||||
// Fetch all follow IDs of the entries ccontained in this list.
|
||||
listFollowIDs, err := t.state.DB.GetFollowIDsInList(ctx, list.ID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting list entry follow ids: %w", err)
|
||||
}
|
||||
|
||||
// don't return statuses more than 24hr in the future
|
||||
maxID = id.NewULIDFromTime(time.Now().Add(future))
|
||||
}
|
||||
// Exclusive list, index all its follow IDs.
|
||||
for _, followID := range listFollowIDs {
|
||||
ignoreFollowIDs[followID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// return only statuses LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("status.id"), maxID)
|
||||
// Extract just the accountID from each follow,
|
||||
// ignoring follows that are in exclusive lists.
|
||||
targetAccountIDs := make([]string, 0, len(follows)+1)
|
||||
for _, f := range follows {
|
||||
_, ignore := ignoreFollowIDs[f.ID]
|
||||
if !ignore {
|
||||
targetAccountIDs = append(
|
||||
targetAccountIDs,
|
||||
f.TargetAccountID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if sinceID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), sinceID)
|
||||
}
|
||||
// Add accountID itself as a pseudo follow so that
|
||||
// accountID can see its own posts in the timeline.
|
||||
targetAccountIDs = append(targetAccountIDs, accountID)
|
||||
|
||||
if minID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
||||
// Select only statuses authored by
|
||||
// accounts with IDs in the slice.
|
||||
q = q.Where(
|
||||
"? IN (?)",
|
||||
bun.Ident("account_id"),
|
||||
bun.In(targetAccountIDs),
|
||||
)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
}
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("NOT ? = ?", bun.Ident("pending_approval"), true)
|
||||
|
||||
if local {
|
||||
// return only statuses posted by local account havers
|
||||
q = q.Where("? = ?", bun.Ident("status.local"), local)
|
||||
}
|
||||
|
||||
// Select only statuses authored by
|
||||
// accounts with IDs in the slice.
|
||||
q = q.Where(
|
||||
"? IN (?)",
|
||||
bun.Ident("status.account_id"),
|
||||
bun.In(targetAccountIDs),
|
||||
return q, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true)
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of statuses returned
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("status.id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("status.id ASC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(statusIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want statuses
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return status IDs loaded from cache + db.
|
||||
return t.state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
}
|
||||
|
||||
func (t *timelineDB) GetPublicTimeline(
|
||||
ctx context.Context,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
local bool,
|
||||
) ([]*gtsmodel.Status, error) {
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
func (t *timelineDB) GetPublicTimeline(ctx context.Context, page *paging.Page) ([]*gtsmodel.Status, error) {
|
||||
return loadStatusTimelinePage(ctx, t.db, t.state,
|
||||
|
||||
if local {
|
||||
return t.getLocalTimeline(
|
||||
ctx,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
// Paging
|
||||
// params.
|
||||
page,
|
||||
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
statusIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
func(q *bun.SelectQuery) (*bun.SelectQuery, error) {
|
||||
// Public only.
|
||||
q = q.Where("? = ?", bun.Ident("visibility"), gtsmodel.VisibilityPublic)
|
||||
|
||||
// Ignore boosts.
|
||||
q = q.Where("? IS NULL", bun.Ident("boost_of_id"))
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("? = ?", bun.Ident("pending_approval"), false)
|
||||
|
||||
return q, nil
|
||||
},
|
||||
)
|
||||
|
||||
q := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
|
||||
// Public only.
|
||||
Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
|
||||
// Ignore boosts.
|
||||
Where("? IS NULL", bun.Ident("status.boost_of_id")).
|
||||
// Only include statuses that aren't pending approval.
|
||||
Where("? = ?", bun.Ident("status.pending_approval"), false).
|
||||
// Select only IDs from table
|
||||
Column("status.id")
|
||||
|
||||
if maxID == "" || maxID >= id.Highest {
|
||||
const future = 24 * time.Hour
|
||||
|
||||
// don't return statuses more than 24hr in the future
|
||||
maxID = id.NewULIDFromTime(time.Now().Add(future))
|
||||
}
|
||||
|
||||
// return only statuses LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("status.id"), maxID)
|
||||
|
||||
if sinceID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), sinceID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of statuses returned
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("status.id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("status.id ASC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(statusIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want statuses
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return status IDs loaded from cache + db.
|
||||
return t.state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
}
|
||||
|
||||
func (t *timelineDB) getLocalTimeline(
|
||||
ctx context.Context,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) ([]*gtsmodel.Status, error) {
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
statusIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
func (t *timelineDB) GetLocalTimeline(ctx context.Context, page *paging.Page) ([]*gtsmodel.Status, error) {
|
||||
return loadStatusTimelinePage(ctx, t.db, t.state,
|
||||
|
||||
// Paging
|
||||
// params.
|
||||
page,
|
||||
|
||||
func(q *bun.SelectQuery) (*bun.SelectQuery, error) {
|
||||
// Local only.
|
||||
q = q.Where("? = ?", bun.Ident("local"), true)
|
||||
|
||||
// Public only.
|
||||
q = q.Where("? = ?", bun.Ident("visibility"), gtsmodel.VisibilityPublic)
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("? = ?", bun.Ident("pending_approval"), false)
|
||||
|
||||
// Ignore boosts.
|
||||
q = q.Where("? IS NULL", bun.Ident("boost_of_id"))
|
||||
|
||||
return q, nil
|
||||
},
|
||||
)
|
||||
|
||||
q := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
|
||||
// Local only.
|
||||
Where("? = ?", bun.Ident("status.local"), true).
|
||||
// Public only.
|
||||
Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
|
||||
// Only include statuses that aren't pending approval.
|
||||
Where("? = ?", bun.Ident("status.pending_approval"), false).
|
||||
// Ignore boosts.
|
||||
Where("? IS NULL", bun.Ident("status.boost_of_id")).
|
||||
// Select only IDs from table
|
||||
Column("status.id")
|
||||
|
||||
if maxID == "" || maxID >= id.Highest {
|
||||
const future = 24 * time.Hour
|
||||
|
||||
// don't return statuses more than 24hr in the future
|
||||
maxID = id.NewULIDFromTime(time.Now().Add(future))
|
||||
}
|
||||
|
||||
// return only statuses LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("status.id"), maxID)
|
||||
|
||||
if sinceID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), sinceID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of statuses returned
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("status.id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("status.id ASC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(statusIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want statuses
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return status IDs loaded from cache + db.
|
||||
return t.state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
}
|
||||
|
||||
// TODO optimize this query and the logic here, because it's slow as balls -- it takes like a literal second to return with a limit of 20!
|
||||
// It might be worth serving it through a timeline instead of raw DB queries, like we do for Home feeds.
|
||||
func (t *timelineDB) GetFavedTimeline(ctx context.Context, accountID string, maxID string, minID string, limit int) ([]*gtsmodel.Status, string, string, error) {
|
||||
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
|
|
@ -442,205 +241,130 @@ func (t *timelineDB) GetFavedTimeline(ctx context.Context, accountID string, max
|
|||
return statuses, nextMaxID, prevMinID, nil
|
||||
}
|
||||
|
||||
func (t *timelineDB) GetListTimeline(
|
||||
func (t *timelineDB) GetListTimeline(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Status, error) {
|
||||
return loadStatusTimelinePage(ctx, t.db, t.state,
|
||||
|
||||
// Paging
|
||||
// params.
|
||||
page,
|
||||
|
||||
// The actual meat of the list-timeline query, outside
|
||||
// of any paging parameters, it selects by list entries.
|
||||
func(q *bun.SelectQuery) (*bun.SelectQuery, error) {
|
||||
|
||||
// Fetch all follow IDs contained in list from DB.
|
||||
followIDs, err := t.state.DB.GetFollowIDsInList(
|
||||
ctx, listID, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf("error getting follows in list: %w", err)
|
||||
}
|
||||
|
||||
// Select target account
|
||||
// IDs from list follows.
|
||||
subQ := t.db.NewSelect().
|
||||
Table("follows").
|
||||
Column("follows.target_account_id").
|
||||
Where("? IN (?)", bun.Ident("follows.id"), bun.In(followIDs))
|
||||
q = q.Where("? IN (?)", bun.Ident("statuses.account_id"), subQ)
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("NOT ? = ?", bun.Ident("pending_approval"), true)
|
||||
|
||||
return q, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (t *timelineDB) GetTagTimeline(ctx context.Context, tagID string, page *paging.Page) ([]*gtsmodel.Status, error) {
|
||||
return loadStatusTimelinePage(ctx, t.db, t.state,
|
||||
|
||||
// Paging
|
||||
// params.
|
||||
page,
|
||||
|
||||
// The actual meat of the list-timeline query, outside of any
|
||||
// paging params, selects by status tags with public visibility.
|
||||
func(q *bun.SelectQuery) (*bun.SelectQuery, error) {
|
||||
|
||||
// ...
|
||||
q = q.Join(
|
||||
"INNER JOIN ? ON ? = ?",
|
||||
bun.Ident("status_to_tags"),
|
||||
bun.Ident("statuses.id"), bun.Ident("status_to_tags.status_id"),
|
||||
)
|
||||
|
||||
// This tag only.
|
||||
q = q.Where("? = ?", bun.Ident("status_to_tags.tag_id"), tagID)
|
||||
|
||||
// Public only.
|
||||
q = q.Where("? = ?", bun.Ident("visibility"), gtsmodel.VisibilityPublic)
|
||||
|
||||
return q, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func loadStatusTimelinePage(
|
||||
ctx context.Context,
|
||||
listID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) ([]*gtsmodel.Status, error) {
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
db *bun.DB,
|
||||
state *state.State,
|
||||
page *paging.Page,
|
||||
query func(*bun.SelectQuery) (*bun.SelectQuery, error),
|
||||
) (
|
||||
[]*gtsmodel.Status,
|
||||
error,
|
||||
) {
|
||||
// Extract page params.
|
||||
minID := page.Min.Value
|
||||
maxID := page.Max.Value
|
||||
limit := page.Limit
|
||||
order := page.Order()
|
||||
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
statusIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
)
|
||||
// Pre-allocate slice of IDs as dest.
|
||||
statusIDs := make([]string, 0, limit)
|
||||
|
||||
// Fetch all follow IDs contained in list from DB.
|
||||
followIDs, err := t.state.DB.GetFollowIDsInList(
|
||||
ctx, listID, nil,
|
||||
)
|
||||
// Now start building the database query.
|
||||
//
|
||||
// Select the following:
|
||||
// - status ID
|
||||
q := db.NewSelect().
|
||||
Table("statuses").
|
||||
Column("id")
|
||||
|
||||
// Append caller
|
||||
// query details.
|
||||
q, err := query(q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting follows in list: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If there's no list follows we can't
|
||||
// possibly return anything for this list.
|
||||
if len(followIDs) == 0 {
|
||||
return make([]*gtsmodel.Status, 0), nil
|
||||
}
|
||||
|
||||
// Select target account IDs from follows.
|
||||
subQ := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")).
|
||||
Column("follow.target_account_id").
|
||||
Where("? IN (?)", bun.Ident("follow.id"), bun.In(followIDs))
|
||||
|
||||
// Select only status IDs created
|
||||
// by one of the followed accounts.
|
||||
q := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
|
||||
// Select only IDs from table
|
||||
Column("status.id").
|
||||
Where("? IN (?)", bun.Ident("status.account_id"), subQ)
|
||||
|
||||
if maxID == "" || maxID >= id.Highest {
|
||||
const future = 24 * time.Hour
|
||||
|
||||
// don't return statuses more than 24hr in the future
|
||||
maxID = id.NewULIDFromTime(time.Now().Add(future))
|
||||
}
|
||||
|
||||
// return only statuses LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("status.id"), maxID)
|
||||
|
||||
if sinceID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), sinceID)
|
||||
if maxID != "" {
|
||||
// Set a maximum ID boundary if was given.
|
||||
q = q.Where("? < ?", bun.Ident("id"), maxID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
// Set a minimum ID boundary if was given.
|
||||
q = q.Where("? > ?", bun.Ident("id"), minID)
|
||||
}
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true)
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of statuses returned
|
||||
q = q.Limit(limit)
|
||||
// Set query ordering.
|
||||
if order.Ascending() {
|
||||
q = q.OrderExpr("? ASC", bun.Ident("id"))
|
||||
} else /* i.e. descending */ {
|
||||
q = q.OrderExpr("? DESC", bun.Ident("id"))
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("status.id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("status.id ASC")
|
||||
}
|
||||
// A limit should always
|
||||
// be supplied for this.
|
||||
q = q.Limit(limit)
|
||||
|
||||
// Finally, perform query into status ID slice.
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(statusIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want statuses
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return status IDs loaded from cache + db.
|
||||
return t.state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
}
|
||||
|
||||
func (t *timelineDB) GetTagTimeline(
|
||||
ctx context.Context,
|
||||
tagID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) ([]*gtsmodel.Status, error) {
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
statusIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
)
|
||||
|
||||
q := t.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("status_to_tags"), bun.Ident("status_to_tag")).
|
||||
Column("status_to_tag.status_id").
|
||||
// Join with statuses for filtering.
|
||||
Join(
|
||||
"INNER JOIN ? AS ? ON ? = ?",
|
||||
bun.Ident("statuses"), bun.Ident("status"),
|
||||
bun.Ident("status.id"), bun.Ident("status_to_tag.status_id"),
|
||||
).
|
||||
// Public only.
|
||||
Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
|
||||
// This tag only.
|
||||
Where("? = ?", bun.Ident("status_to_tag.tag_id"), tagID)
|
||||
|
||||
if maxID == "" || maxID >= id.Highest {
|
||||
const future = 24 * time.Hour
|
||||
|
||||
// don't return statuses more than 24hr in the future
|
||||
maxID = id.NewULIDFromTime(time.Now().Add(future))
|
||||
}
|
||||
|
||||
// return only statuses LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("status_to_tag.status_id"), maxID)
|
||||
|
||||
if sinceID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("status_to_tag.status_id"), sinceID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// return only statuses HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("status_to_tag.status_id"), minID)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
}
|
||||
|
||||
// Only include statuses that aren't pending approval.
|
||||
q = q.Where("NOT ? = ?", bun.Ident("status.pending_approval"), true)
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of statuses returned
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("status_to_tag.status_id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("status_to_tag.status_id ASC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(statusIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want statuses
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return status IDs loaded from cache + db.
|
||||
return t.state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
// Fetch statuses from DB / cache with given IDs.
|
||||
return state.DB.GetStatusesByIDs(ctx, statusIDs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,14 +20,12 @@ package bundb_test
|
|||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
|
|
@ -35,38 +33,6 @@ type TimelineTestSuite struct {
|
|||
BunDBStandardTestSuite
|
||||
}
|
||||
|
||||
func getFutureStatus() *gtsmodel.Status {
|
||||
theDistantFuture := time.Now().Add(876600 * time.Hour)
|
||||
id := id.NewULIDFromTime(theDistantFuture)
|
||||
|
||||
return >smodel.Status{
|
||||
ID: id,
|
||||
URI: "http://localhost:8080/users/admin/statuses/" + id,
|
||||
URL: "http://localhost:8080/@admin/statuses/" + id,
|
||||
Content: "it's the future, wooooooooooooooooooooooooooooooooo",
|
||||
Text: "it's the future, wooooooooooooooooooooooooooooooooo",
|
||||
ContentType: gtsmodel.StatusContentTypePlain,
|
||||
AttachmentIDs: []string{},
|
||||
TagIDs: []string{},
|
||||
MentionIDs: []string{},
|
||||
EmojiIDs: []string{},
|
||||
CreatedAt: theDistantFuture,
|
||||
Local: util.Ptr(true),
|
||||
AccountURI: "http://localhost:8080/users/admin",
|
||||
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||
InReplyToID: "",
|
||||
BoostOfID: "",
|
||||
ContentWarning: "",
|
||||
Visibility: gtsmodel.VisibilityPublic,
|
||||
Sensitive: util.Ptr(false),
|
||||
Language: "en",
|
||||
CreatedWithApplicationID: "01F8MGXQRHYF5QPMTMXP78QC2F",
|
||||
Federated: util.Ptr(true),
|
||||
InteractionPolicy: gtsmodel.DefaultInteractionPolicyPublic(),
|
||||
ActivityStreamsType: ap.ObjectNote,
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) publicCount() int {
|
||||
var publicCount int
|
||||
for _, status := range suite.testStatuses {
|
||||
|
|
@ -92,7 +58,7 @@ func (suite *TimelineTestSuite) localCount() int {
|
|||
return localCount
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) checkStatuses(statuses []*gtsmodel.Status, maxID string, minID string, expectedLength int) {
|
||||
func (suite *TimelineTestSuite) checkStatuses(statuses []*gtsmodel.Status, maxID string, minID string, expectedOrder paging.Order, expectedLength int) {
|
||||
if l := len(statuses); l != expectedLength {
|
||||
suite.FailNowf("", "expected %d statuses in slice, got %d", expectedLength, l)
|
||||
} else if l == 0 {
|
||||
|
|
@ -100,74 +66,73 @@ func (suite *TimelineTestSuite) checkStatuses(statuses []*gtsmodel.Status, maxID
|
|||
return
|
||||
}
|
||||
|
||||
// Check ordering + bounds of statuses.
|
||||
highest := statuses[0].ID
|
||||
for _, status := range statuses {
|
||||
id := status.ID
|
||||
if expectedOrder.Ascending() {
|
||||
// Check ordering + bounds of statuses.
|
||||
lowest := statuses[0].ID
|
||||
for _, status := range statuses {
|
||||
id := status.ID
|
||||
|
||||
if id >= maxID {
|
||||
suite.FailNowf("", "%s greater than maxID %s", id, maxID)
|
||||
if id >= maxID {
|
||||
suite.FailNowf("", "%s greater than maxID %s", id, maxID)
|
||||
}
|
||||
|
||||
if id <= minID {
|
||||
suite.FailNowf("", "%s smaller than minID %s", id, minID)
|
||||
}
|
||||
|
||||
if id < lowest {
|
||||
suite.FailNowf("", "statuses in slice were not ordered lowest -> highest ID")
|
||||
}
|
||||
|
||||
lowest = id
|
||||
}
|
||||
} else {
|
||||
// Check ordering + bounds of statuses.
|
||||
highest := statuses[0].ID
|
||||
for _, status := range statuses {
|
||||
id := status.ID
|
||||
|
||||
if id <= minID {
|
||||
suite.FailNowf("", "%s smaller than minID %s", id, minID)
|
||||
if id >= maxID {
|
||||
suite.FailNowf("", "%s greater than maxID %s", id, maxID)
|
||||
}
|
||||
|
||||
if id <= minID {
|
||||
suite.FailNowf("", "%s smaller than minID %s", id, minID)
|
||||
}
|
||||
|
||||
if id > highest {
|
||||
suite.FailNowf("", "statuses in slice were not ordered highest -> lowest ID")
|
||||
}
|
||||
|
||||
highest = id
|
||||
}
|
||||
|
||||
if id > highest {
|
||||
suite.FailNowf("", "statuses in slice were not ordered highest -> lowest ID")
|
||||
}
|
||||
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetPublicTimeline() {
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := suite.db.GetPublicTimeline(ctx, "", "", "", 20, false)
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
s, err := suite.db.GetPublicTimeline(ctx, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.T().Log(kv.Field{
|
||||
K: "statuses", V: s,
|
||||
})
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, suite.publicCount())
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), suite.publicCount())
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetPublicTimelineLocal() {
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := suite.db.GetPublicTimeline(ctx, "", "", "", 20, true)
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
s, err := suite.db.GetLocalTimeline(ctx, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.T().Log(kv.Field{
|
||||
K: "statuses", V: s,
|
||||
})
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, suite.localCount())
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetPublicTimelineWithFutureStatus() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a status set far in the
|
||||
// future, it shouldn't be retrieved.
|
||||
futureStatus := getFutureStatus()
|
||||
if err := suite.db.PutStatus(ctx, futureStatus); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
s, err := suite.db.GetPublicTimeline(ctx, "", "", "", 20, false)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.NotContains(s, futureStatus)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, suite.publicCount())
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), suite.localCount())
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimeline() {
|
||||
|
|
@ -176,12 +141,14 @@ func (suite *TimelineTestSuite) TestGetHomeTimeline() {
|
|||
viewingAccount = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", "", 20, false)
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 20)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 20)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
|
||||
|
|
@ -201,13 +168,15 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
|
|||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
// First try with list just set to exclusive.
|
||||
// We should only get zork's own statuses.
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", "", 20, false)
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 9)
|
||||
|
||||
// Remove admin account from the exclusive list.
|
||||
listEntry := suite.testListEntries["local_account_1_list_1_entry_2"]
|
||||
|
|
@ -217,11 +186,11 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
|
|||
|
||||
// Zork should only see their own
|
||||
// statuses and admin's statuses now.
|
||||
s, err = suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", "", 20, false)
|
||||
s, err = suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 13)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
|
||||
|
|
@ -246,36 +215,16 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() {
|
|||
}
|
||||
}
|
||||
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
// Query should work fine; though far
|
||||
// fewer statuses will be returned ofc.
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", "", 20, false)
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 9)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
viewingAccount = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
// Insert a status set far in the
|
||||
// future, it shouldn't be retrieved.
|
||||
futureStatus := getFutureStatus()
|
||||
if err := suite.db.PutStatus(ctx, futureStatus); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", "", 20, false)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.NotContains(s, futureStatus)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 20)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 9)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimelineBackToFront() {
|
||||
|
|
@ -284,14 +233,16 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineBackToFront() {
|
|||
viewingAccount = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", id.Lowest, 5, false)
|
||||
page := toPage("", "", id.Lowest, 5)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
|
||||
suite.Equal("01F8MHAYFKS4KMXF8K5Y1C0KRN", s[0].ID)
|
||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[len(s)-1].ID)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 5)
|
||||
suite.Equal("01F8MHAYFKS4KMXF8K5Y1C0KRN", s[len(s)-1].ID)
|
||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[0].ID)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
|
||||
|
|
@ -300,12 +251,14 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
|
|||
viewingAccount = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, id.Highest, "", "", 5, false)
|
||||
page := toPage(id.Highest, "", "", 5)
|
||||
|
||||
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 5)
|
||||
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
|
||||
suite.Equal("01HEN2RZ8BG29Y5Z9VJC73HZW7", s[len(s)-1].ID)
|
||||
}
|
||||
|
|
@ -316,12 +269,14 @@ func (suite *TimelineTestSuite) TestGetListTimelineNoParams() {
|
|||
list = suite.testLists["local_account_1_list_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, "", "", "", 20)
|
||||
page := toPage("", "", "", 20)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 13)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 13)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
|
||||
|
|
@ -330,12 +285,14 @@ func (suite *TimelineTestSuite) TestGetListTimelineMaxID() {
|
|||
list = suite.testLists["local_account_1_list_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, id.Highest, "", "", 5)
|
||||
page := toPage(id.Highest, "", "", 5)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 5)
|
||||
suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID)
|
||||
suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", s[len(s)-1].ID)
|
||||
}
|
||||
|
|
@ -346,14 +303,16 @@ func (suite *TimelineTestSuite) TestGetListTimelineMinID() {
|
|||
list = suite.testLists["local_account_1_list_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, "", "", id.Lowest, 5)
|
||||
page := toPage("", "", id.Lowest, 5)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 5)
|
||||
suite.Equal("01F8MHC8VWDRBQR0N1BATDDEM5", s[0].ID)
|
||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[len(s)-1].ID)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 5)
|
||||
suite.Equal("01F8MHC8VWDRBQR0N1BATDDEM5", s[len(s)-1].ID)
|
||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[0].ID)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetListTimelineMinIDPagingUp() {
|
||||
|
|
@ -362,14 +321,16 @@ func (suite *TimelineTestSuite) TestGetListTimelineMinIDPagingUp() {
|
|||
list = suite.testLists["local_account_1_list_1"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, "", "", "01F8MHC8VWDRBQR0N1BATDDEM5", 5)
|
||||
page := toPage("", "", "01F8MHC8VWDRBQR0N1BATDDEM5", 5)
|
||||
|
||||
s, err := suite.db.GetListTimeline(ctx, list.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, "01F8MHC8VWDRBQR0N1BATDDEM5", 5)
|
||||
suite.Equal("01G20ZM733MGN8J344T4ZDDFY1", s[0].ID)
|
||||
suite.Equal("01F8MHCP5P2NWYQ416SBA0XSEV", s[len(s)-1].ID)
|
||||
suite.checkStatuses(s, id.Highest, "01F8MHC8VWDRBQR0N1BATDDEM5", page.Order(), 5)
|
||||
suite.Equal("01G20ZM733MGN8J344T4ZDDFY1", s[len(s)-1].ID)
|
||||
suite.Equal("01F8MHCP5P2NWYQ416SBA0XSEV", s[0].ID)
|
||||
}
|
||||
|
||||
func (suite *TimelineTestSuite) TestGetTagTimelineNoParams() {
|
||||
|
|
@ -378,15 +339,33 @@ func (suite *TimelineTestSuite) TestGetTagTimelineNoParams() {
|
|||
tag = suite.testTags["welcome"]
|
||||
)
|
||||
|
||||
s, err := suite.db.GetTagTimeline(ctx, tag.ID, "", "", "", 1)
|
||||
page := toPage("", "", "", 1)
|
||||
|
||||
s, err := suite.db.GetTagTimeline(ctx, tag.ID, page)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, 1)
|
||||
suite.checkStatuses(s, id.Highest, id.Lowest, page.Order(), 1)
|
||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[0].ID)
|
||||
}
|
||||
|
||||
func TestTimelineTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(TimelineTestSuite))
|
||||
}
|
||||
|
||||
// toPage is a helper function to wrap a series of paging arguments in paging.Page{}.
|
||||
func toPage(maxID, sinceID, minID string, limit int) *paging.Page {
|
||||
var pg paging.Page
|
||||
pg.Limit = limit
|
||||
|
||||
if maxID != "" {
|
||||
pg.Max = paging.MaxID(maxID)
|
||||
}
|
||||
|
||||
if sinceID != "" || minID != "" {
|
||||
pg.Min = paging.EitherMinID(minID, sinceID)
|
||||
}
|
||||
|
||||
return &pg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ type List interface {
|
|||
// GetListsByAccountID gets all lists owned by the given accountID.
|
||||
GetListsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error)
|
||||
|
||||
// GetListIDsByAccountID gets the IDs of all lists owned by the given accountID.
|
||||
GetListIDsByAccountID(ctx context.Context, accountID string) ([]string, error)
|
||||
|
||||
// CountListsByAccountID counts the number of lists owned by the given accountID.
|
||||
CountListsByAccountID(ctx context.Context, accountID string) (int, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,20 +21,20 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// Timeline contains functionality for retrieving home/public/faved etc timelines for an account.
|
||||
type Timeline interface {
|
||||
// GetHomeTimeline returns a slice of statuses from accounts that are followed by the given account id.
|
||||
//
|
||||
// Statuses should be returned in descending order of when they were created (newest first).
|
||||
GetHomeTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error)
|
||||
GetHomeTimeline(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Status, error)
|
||||
|
||||
// GetPublicTimeline fetches the account's PUBLIC timeline -- ie., posts and replies that are public.
|
||||
// It will use the given filters and try to return as many statuses as possible up to the limit.
|
||||
//
|
||||
// Statuses should be returned in descending order of when they were created (newest first).
|
||||
GetPublicTimeline(ctx context.Context, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error)
|
||||
GetPublicTimeline(ctx context.Context, page *paging.Page) ([]*gtsmodel.Status, error)
|
||||
|
||||
// GetLocalTimeline fetches the account's LOCAL timeline -- i.e. PUBLIC posts by LOCAL users.
|
||||
GetLocalTimeline(ctx context.Context, page *paging.Page) ([]*gtsmodel.Status, error)
|
||||
|
||||
// GetFavedTimeline fetches the account's FAVED timeline -- ie., posts and replies that the requesting account has faved.
|
||||
// It will use the given filters and try to return as many statuses as possible up to the limit.
|
||||
|
|
@ -46,10 +46,8 @@ type Timeline interface {
|
|||
GetFavedTimeline(ctx context.Context, accountID string, maxID string, minID string, limit int) ([]*gtsmodel.Status, string, string, error)
|
||||
|
||||
// GetListTimeline returns a slice of statuses from followed accounts collected within the list with the given listID.
|
||||
// Statuses should be returned in descending order of when they were created (newest first).
|
||||
GetListTimeline(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Status, error)
|
||||
GetListTimeline(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Status, error)
|
||||
|
||||
// GetTagTimeline returns a slice of public-visibility statuses that use the given tagID.
|
||||
// Statuses should be returned in descending order of when they were created (newest first).
|
||||
GetTagTimeline(ctx context.Context, tagID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.Status, error)
|
||||
GetTagTimeline(ctx context.Context, tagID string, page *paging.Page) ([]*gtsmodel.Status, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,12 +77,6 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
|
|||
suite.intFilter = interaction.NewFilter(&suite.state)
|
||||
suite.media = testrig.NewTestMediaManager(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
suite.visFilter,
|
||||
suite.converter,
|
||||
)
|
||||
|
||||
suite.client = testrig.NewMockHTTPClient(nil, "../../../testrig/media")
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.DB = suite.db
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/admin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||
|
|
@ -80,12 +79,6 @@ func (suite *FederatingDBTestSuite) SetupTest() {
|
|||
suite.testActivities = testrig.NewTestActivities(suite.testAccounts)
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.federatingDB = testrig.NewTestFederatingDB(&suite.state)
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
|
||||
|
|
|
|||
|
|
@ -136,10 +136,7 @@ func (f *federatingDB) undoFollow(
|
|||
|
||||
// Convert AS Follow to barebones *gtsmodel.Follow,
|
||||
// retrieving origin + target accts from the db.
|
||||
follow, err := f.converter.ASFollowToFollow(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
asFollow,
|
||||
)
|
||||
follow, err := f.converter.ASFollowToFollow(ctx, asFollow)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("error converting AS Follow to follow: %w", err)
|
||||
return err
|
||||
|
|
@ -152,6 +149,11 @@ func (f *federatingDB) undoFollow(
|
|||
return nil
|
||||
}
|
||||
|
||||
// Lock on the Follow URI
|
||||
// as we may be updating it.
|
||||
unlock := f.state.FedLocks.Lock(follow.URI)
|
||||
defer unlock()
|
||||
|
||||
// Ensure addressee is follow target.
|
||||
if follow.TargetAccountID != receivingAcct.ID {
|
||||
const text = "receivingAcct was not Follow target"
|
||||
|
|
@ -178,7 +180,16 @@ func (f *federatingDB) undoFollow(
|
|||
return err
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Follow undone")
|
||||
// Send the deleted follow through to
|
||||
// the fedi worker to process side effects.
|
||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||
APObjectType: ap.ActivityFollow,
|
||||
APActivityType: ap.ActivityUndo,
|
||||
GTSModel: follow,
|
||||
Receiving: receivingAcct,
|
||||
Requesting: requestingAcct,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -269,7 +280,16 @@ func (f *federatingDB) undoLike(
|
|||
return err
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Like undone")
|
||||
// Send the deleted block through to
|
||||
// the fedi worker to process side effects.
|
||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||
APObjectType: ap.ActivityLike,
|
||||
APActivityType: ap.ActivityUndo,
|
||||
GTSModel: fave,
|
||||
Receiving: receivingAcct,
|
||||
Requesting: requestingAcct,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -298,10 +318,7 @@ func (f *federatingDB) undoBlock(
|
|||
|
||||
// Convert AS Block to barebones *gtsmodel.Block,
|
||||
// retrieving origin + target accts from the DB.
|
||||
block, err := f.converter.ASBlockToBlock(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
asBlock,
|
||||
)
|
||||
block, err := f.converter.ASBlockToBlock(ctx, asBlock)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("error converting AS Block to block: %w", err)
|
||||
return err
|
||||
|
|
@ -333,7 +350,16 @@ func (f *federatingDB) undoBlock(
|
|||
return err
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Block undone")
|
||||
// Send the deleted block through to
|
||||
// the fedi worker to process side effects.
|
||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||
APObjectType: ap.ActivityBlock,
|
||||
APActivityType: ap.ActivityUndo,
|
||||
GTSModel: block,
|
||||
Receiving: receivingAcct,
|
||||
Requesting: requestingAcct,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
|
|
@ -67,12 +66,6 @@ func (suite *FederatorStandardTestSuite) SetupTest() {
|
|||
suite.state.Storage = suite.storage
|
||||
suite.typeconverter = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.typeconverter,
|
||||
)
|
||||
|
||||
// Ensure it's possible to deref
|
||||
// main key of foss satan.
|
||||
fossSatanAS, err := suite.typeconverter.AccountToAS(context.Background(), suite.testAccounts["remote_account_1"])
|
||||
|
|
|
|||
51
internal/id/page.go
Normal file
51
internal/id/page.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// 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 id
|
||||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
// ValidatePage ensures that passed page has valid paging
|
||||
// values for the current defined ordering. That is, it
|
||||
// ensures a valid page *cursor* value, using id.Highest
|
||||
// or id.Lowest where appropriate when none given.
|
||||
func ValidatePage(page *paging.Page) {
|
||||
if page == nil {
|
||||
// unpaged
|
||||
return
|
||||
}
|
||||
|
||||
switch page.Order() {
|
||||
// If the page order is ascending,
|
||||
// ensure that a minimum value is set.
|
||||
// This will be used as the cursor.
|
||||
case paging.OrderAscending:
|
||||
if page.Min.Value == "" {
|
||||
page.Min.Value = Lowest
|
||||
}
|
||||
|
||||
// If the page order is descending,
|
||||
// ensure that a maximum value is set.
|
||||
// This will be used as the cursor.
|
||||
case paging.OrderDescending:
|
||||
if page.Max.Value == "" {
|
||||
page.Max.Value = Highest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,13 +21,11 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/admin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/transport"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -60,12 +58,6 @@ func (suite *MediaStandardTestSuite) SetupTest() {
|
|||
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testEmojis = testrig.NewTestEmojis()
|
||||
|
|
|
|||
|
|
@ -64,10 +64,11 @@ func (p *Page) GetOrder() Order {
|
|||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return p.order()
|
||||
return p.Order()
|
||||
}
|
||||
|
||||
func (p *Page) order() Order {
|
||||
// Order is a small helper function to return page sort ordering.
|
||||
func (p *Page) Order() Order {
|
||||
switch {
|
||||
case p.Min.Order != 0:
|
||||
return p.Min.Order
|
||||
|
|
@ -90,7 +91,7 @@ func (p *Page) Page(in []string) []string {
|
|||
return in
|
||||
}
|
||||
|
||||
if p.order().Ascending() {
|
||||
if p.Order().Ascending() {
|
||||
// Sort type is ascending, input
|
||||
// data is assumed to be ascending.
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ func Page_PageFunc[WithID any](p *Page, in []WithID, get func(WithID) string) []
|
|||
return in
|
||||
}
|
||||
|
||||
if p.order().Ascending() {
|
||||
if p.Order().Ascending() {
|
||||
// Sort type is ascending, input
|
||||
// data is assumed to be ascending.
|
||||
|
||||
|
|
|
|||
|
|
@ -95,12 +95,6 @@ func (suite *AccountStandardTestSuite) SetupTest() {
|
|||
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
|
|
@ -92,12 +92,6 @@ func (suite *AdminStandardTestSuite) SetupTest() {
|
|||
suite.state.AdminActions = adminactions.New(suite.state.DB, &suite.state.Workers)
|
||||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
|
|
@ -306,25 +306,10 @@ func (p *Processor) InvalidateTimelinedStatus(ctx context.Context, accountID str
|
|||
return gtserror.Newf("db error getting lists for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Start new log entry with
|
||||
// the above calling func's name.
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
WithField("caller", log.Caller(3)).
|
||||
WithField("accountID", accountID).
|
||||
WithField("statusID", statusID)
|
||||
|
||||
// Unprepare item from home + list timelines, just log
|
||||
// if something goes wrong since this is not a showstopper.
|
||||
|
||||
if err := p.state.Timelines.Home.UnprepareItem(ctx, accountID, statusID); err != nil {
|
||||
l.Errorf("error unpreparing item from home timeline: %v", err)
|
||||
}
|
||||
|
||||
// Unprepare item from home + list timelines.
|
||||
p.state.Caches.Timelines.Home.MustGet(accountID).UnprepareByStatusIDs(statusID)
|
||||
for _, list := range lists {
|
||||
if err := p.state.Timelines.List.UnprepareItem(ctx, list.ID, statusID); err != nil {
|
||||
l.Errorf("error unpreparing item from list timeline %s: %v", list.ID, err)
|
||||
}
|
||||
p.state.Caches.Timelines.List.MustGet(list.ID).UnprepareByStatusIDs(statusID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -106,12 +106,6 @@ func (suite *ConversationsTestSuite) SetupTest() {
|
|||
suite.tc = typeutils.NewConverter(&suite.state)
|
||||
suite.filter = visibility.NewFilter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
suite.filter,
|
||||
suite.tc,
|
||||
)
|
||||
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
|
|
@ -109,12 +109,6 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
|
|||
suite.state.Storage = suite.storage
|
||||
suite.typeconverter = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.typeconverter,
|
||||
)
|
||||
|
||||
suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media")
|
||||
suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople()
|
||||
suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses()
|
||||
|
|
|
|||
|
|
@ -93,11 +93,6 @@ func (suite *StatusStandardTestSuite) SetupTest() {
|
|||
|
||||
visFilter := visibility.NewFilter(&suite.state)
|
||||
intFilter := interaction.NewFilter(&suite.state)
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visFilter,
|
||||
suite.typeConverter,
|
||||
)
|
||||
|
||||
common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter)
|
||||
polls := polls.New(&common, &suite.state, suite.typeConverter)
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
// 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"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
)
|
||||
|
||||
// SkipInsert returns a function that satisifes SkipInsertFunction.
|
||||
func SkipInsert() timeline.SkipInsertFunction {
|
||||
// Gap to allow between a status or boost of status,
|
||||
// and reinsertion of a new boost of that status.
|
||||
// This is useful to avoid a heavily boosted status
|
||||
// showing up way too often in a user's timeline.
|
||||
const boostReinsertionDepth = 50
|
||||
|
||||
return func(
|
||||
ctx context.Context,
|
||||
newItemID string,
|
||||
newItemAccountID string,
|
||||
newItemBoostOfID string,
|
||||
newItemBoostOfAccountID string,
|
||||
nextItemID string,
|
||||
nextItemAccountID string,
|
||||
nextItemBoostOfID string,
|
||||
nextItemBoostOfAccountID string,
|
||||
depth int,
|
||||
) (bool, error) {
|
||||
if newItemID == nextItemID {
|
||||
// Don't insert duplicates.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if newItemBoostOfID != "" {
|
||||
if newItemBoostOfID == nextItemBoostOfID &&
|
||||
depth < boostReinsertionDepth {
|
||||
// Don't insert boosts of items
|
||||
// we've seen boosted recently.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if newItemBoostOfID == nextItemID &&
|
||||
depth < boostReinsertionDepth {
|
||||
// Don't insert boosts of items when
|
||||
// we've seen the original recently.
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with insertion
|
||||
// (that's what she said!).
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// FavedTimelineGet ...
|
||||
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
|
|
|
|||
|
|
@ -19,132 +19,85 @@ package timeline
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
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/log"
|
||||
"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 gets a pageable timeline of statuses
|
||||
// in the home timeline of the requesting account.
|
||||
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
|
||||
var pageQuery url.Values
|
||||
var postFilter func(*gtsmodel.Status) bool
|
||||
if local {
|
||||
// Set local = true query.
|
||||
pageQuery = localOnlyTrue
|
||||
postFilter = func(s *gtsmodel.Status) bool {
|
||||
return !*s.Local
|
||||
}
|
||||
|
||||
items := make([]timeline.Timelineable, count)
|
||||
for i, s := range statuses {
|
||||
items[i] = s
|
||||
}
|
||||
|
||||
return items, false, nil
|
||||
} else {
|
||||
// Set local = false query.
|
||||
pageQuery = localOnlyFalse
|
||||
postFilter = nil
|
||||
}
|
||||
}
|
||||
return p.getStatusTimeline(ctx,
|
||||
|
||||
// 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
|
||||
}
|
||||
// Auth'd
|
||||
// account.
|
||||
requester,
|
||||
|
||||
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
|
||||
}
|
||||
// Keyed-by-account-ID, home timeline cache.
|
||||
p.state.Caches.Timelines.Home.MustGet(requester.ID),
|
||||
|
||||
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
|
||||
}
|
||||
// Current
|
||||
// page.
|
||||
page,
|
||||
|
||||
return timelineable, nil
|
||||
}
|
||||
}
|
||||
// Home timeline endpoint.
|
||||
"/api/v1/timelines/home",
|
||||
|
||||
// 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
|
||||
}
|
||||
// Set local-only timeline
|
||||
// page query flag, (this map
|
||||
// later gets copied before
|
||||
// any further usage).
|
||||
pageQuery,
|
||||
|
||||
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
|
||||
}
|
||||
// Status filter context.
|
||||
statusfilter.FilterContextHome,
|
||||
|
||||
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
|
||||
}
|
||||
// Database load function.
|
||||
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
|
||||
return p.state.DB.GetHomeTimeline(ctx, requester.ID, pg)
|
||||
},
|
||||
|
||||
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)
|
||||
// Filtering function,
|
||||
// i.e. filter before caching.
|
||||
func(s *gtsmodel.Status) bool {
|
||||
|
||||
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
|
||||
}
|
||||
}
|
||||
// Check the visibility of passed status to requesting user.
|
||||
ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
|
||||
}
|
||||
return !ok
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, count)
|
||||
nextMaxIDValue = statuses[count-1].GetID()
|
||||
prevMinIDValue = statuses[0].GetID()
|
||||
// Post filtering funtion,
|
||||
// i.e. filter after caching.
|
||||
postFilter,
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,13 +23,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,25 +33,7 @@ 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())
|
||||
}
|
||||
|
||||
suite.TimelineStandardTestSuite.TearDownTest()
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +42,6 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
|
|||
var (
|
||||
ctx = context.Background()
|
||||
requester = suite.testAccounts["local_account_1"]
|
||||
authed = &apiutil.Auth{Account: requester}
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus
|
||||
|
|
@ -97,11 +74,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)
|
||||
|
|
@ -114,10 +92,9 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
|
|||
if !filteredStatusFound {
|
||||
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
|
||||
}
|
||||
// Prune the timeline to drop cached prepared statuses, a side effect of this precondition check.
|
||||
if _, err := suite.state.Timelines.Home.Prune(ctx, requester.ID, 0, 0); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Clear the timeline to drop all cached statuses.
|
||||
suite.state.Caches.Timelines.Home.Clear(requester.ID)
|
||||
|
||||
// Create a filter to hide one status on the timeline.
|
||||
if err := suite.db.PutFilter(ctx, filter); err != nil {
|
||||
|
|
@ -127,11 +104,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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,155 +22,93 @@ import (
|
|||
"errors"
|
||||
|
||||
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/log"
|
||||
"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 gets a pageable timeline of statuses
|
||||
// in the list timeline of ID by the requesting account.
|
||||
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)
|
||||
}
|
||||
// Fetch status timeline for list.
|
||||
return p.getStatusTimeline(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()
|
||||
// Keyed-by-list-ID, list timeline cache.
|
||||
p.state.Caches.Timelines.List.MustGet(listID),
|
||||
|
||||
// Current
|
||||
// page.
|
||||
page,
|
||||
|
||||
// List timeline ID's endpoint.
|
||||
"/api/v1/timelines/list/"+listID,
|
||||
|
||||
// No page
|
||||
// query.
|
||||
nil,
|
||||
|
||||
// Status filter context.
|
||||
statusfilter.FilterContextHome,
|
||||
|
||||
// Database load function.
|
||||
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
|
||||
return p.state.DB.GetListTimeline(ctx, listID, pg)
|
||||
},
|
||||
|
||||
// Filtering function,
|
||||
// i.e. filter before caching.
|
||||
func(s *gtsmodel.Status) bool {
|
||||
|
||||
// Check the visibility of passed status to requesting user.
|
||||
ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
|
||||
}
|
||||
return !ok
|
||||
},
|
||||
|
||||
// Post filtering funtion,
|
||||
// i.e. filter after caching.
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// NotificationsGet ...
|
||||
func (p *Processor) NotificationsGet(
|
||||
ctx context.Context,
|
||||
authed *apiutil.Auth,
|
||||
|
|
|
|||
|
|
@ -19,152 +19,143 @@ package timeline
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 gets a pageable timeline of public statuses
|
||||
// for the given requesting account. It ensures that each status
|
||||
// in timeline is visible to the account before returning it.
|
||||
//
|
||||
// The local argument limits this to local-only statuses.
|
||||
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)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
) (
|
||||
*apimodel.PageableResponse,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
if local {
|
||||
return p.localTimelineGet(ctx, requester, page)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
// Nothing relevant (left) in the db.
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
// Page up from first status in slice
|
||||
// (ie., one with the highest ID).
|
||||
prevMinIDValue = statuses[0].ID
|
||||
|
||||
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
|
||||
|
||||
timelineable, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error checking status visibility: %v", err)
|
||||
continue inner
|
||||
}
|
||||
|
||||
if !timelineable {
|
||||
continue inner
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 p.publicTimelineGet(ctx, requester, page)
|
||||
}
|
||||
|
||||
func (p *Processor) publicTimelineGet(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
page *paging.Page,
|
||||
) (
|
||||
*apimodel.PageableResponse,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
return p.getStatusTimeline(ctx,
|
||||
|
||||
// Auth acconut,
|
||||
// can be nil.
|
||||
requester,
|
||||
|
||||
// No cache.
|
||||
nil,
|
||||
|
||||
// Current
|
||||
// page.
|
||||
page,
|
||||
|
||||
// Public timeline endpoint.
|
||||
"/api/v1/timelines/public",
|
||||
|
||||
// Set local-only timeline
|
||||
// page query flag, (this map
|
||||
// later gets copied before
|
||||
// any further usage).
|
||||
localOnlyFalse,
|
||||
|
||||
// Status filter context.
|
||||
statusfilter.FilterContextPublic,
|
||||
|
||||
// Database load function.
|
||||
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
|
||||
return p.state.DB.GetPublicTimeline(ctx, pg)
|
||||
},
|
||||
|
||||
// Pre-filtering function,
|
||||
// i.e. filter before caching.
|
||||
func(s *gtsmodel.Status) bool {
|
||||
|
||||
// Check the visibility of passed status to requesting user.
|
||||
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
|
||||
}
|
||||
return !ok
|
||||
},
|
||||
|
||||
// Post filtering funtion,
|
||||
// i.e. filter after caching.
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Processor) localTimelineGet(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
page *paging.Page,
|
||||
) (
|
||||
*apimodel.PageableResponse,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
return p.getStatusTimeline(ctx,
|
||||
|
||||
// Auth acconut,
|
||||
// can be nil.
|
||||
requester,
|
||||
|
||||
// No cache.
|
||||
nil,
|
||||
|
||||
// Current
|
||||
// page.
|
||||
page,
|
||||
|
||||
// Public timeline endpoint.
|
||||
"/api/v1/timelines/public",
|
||||
|
||||
// Set local-only timeline
|
||||
// page query flag, (this map
|
||||
// later gets copied before
|
||||
// any further usage).
|
||||
localOnlyTrue,
|
||||
|
||||
// Status filter context.
|
||||
statusfilter.FilterContextPublic,
|
||||
|
||||
// Database load function.
|
||||
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
|
||||
return p.state.DB.GetLocalTimeline(ctx, pg)
|
||||
},
|
||||
|
||||
// Filtering function,
|
||||
// i.e. filter before caching.
|
||||
func(s *gtsmodel.Status) bool {
|
||||
|
||||
// Check the visibility of passed status to requesting user.
|
||||
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
|
||||
}
|
||||
return !ok
|
||||
},
|
||||
|
||||
// Post filtering funtion,
|
||||
// i.e. filter after caching.
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -90,9 +93,9 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
|
|||
// some other statuses were filtered out.
|
||||
suite.NoError(errWithCode)
|
||||
suite.Len(resp.Items, 1)
|
||||
suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false>; rel="prev"`, resp.LinkHeader)
|
||||
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false`, resp.NextLink)
|
||||
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false`, resp.PrevLink)
|
||||
suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5>; rel="prev"`, resp.LinkHeader)
|
||||
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV`, resp.NextLink)
|
||||
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5`, resp.PrevLink)
|
||||
}
|
||||
|
||||
// A timeline containing a status hidden due to filtering should return other statuses with no error.
|
||||
|
|
@ -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)
|
||||
|
|
@ -149,8 +153,6 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
|
|||
if !filteredStatusFound {
|
||||
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
|
||||
}
|
||||
// The public timeline has no prepared status cache and doesn't need to be pruned,
|
||||
// as in the home timeline version of this test.
|
||||
|
||||
// Create a filter to hide one status on the timeline.
|
||||
if err := suite.db.PutFilter(ctx, filter); err != nil {
|
||||
|
|
@ -161,10 +163,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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,18 +20,16 @@ package timeline
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
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/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// TagTimelineGet gets a pageable timeline for the given
|
||||
|
|
@ -40,37 +38,77 @@ import (
|
|||
// to requestingAcct before returning it.
|
||||
func (p *Processor) TagTimelineGet(
|
||||
ctx context.Context,
|
||||
requestingAcct *gtsmodel.Account,
|
||||
requester *gtsmodel.Account,
|
||||
tagName string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
|
||||
// Fetch the requested tag with name.
|
||||
tag, errWithCode := p.getTag(ctx, tagName)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
// Check for a useable returned tag for endpoint.
|
||||
if tag == nil || !*tag.Useable || !*tag.Listable {
|
||||
|
||||
// Obey mastodon API by returning 404 for this.
|
||||
err := fmt.Errorf("tag was not found, or not useable/listable on this instance")
|
||||
return nil, gtserror.NewErrorNotFound(err, err.Error())
|
||||
const text = "tag was not found, or not useable/listable on this instance"
|
||||
return nil, gtserror.NewWithCode(http.StatusNotFound, text)
|
||||
}
|
||||
|
||||
statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, maxID, sinceID, minID, limit)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err = gtserror.Newf("db error getting statuses: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
// Fetch status timeline for tag.
|
||||
return p.getStatusTimeline(ctx,
|
||||
|
||||
return p.packageTagResponse(
|
||||
ctx,
|
||||
requestingAcct,
|
||||
statuses,
|
||||
limit,
|
||||
// Use API URL for tag.
|
||||
// Auth'd
|
||||
// account.
|
||||
requester,
|
||||
|
||||
// No
|
||||
// cache.
|
||||
nil,
|
||||
|
||||
// Current
|
||||
// page.
|
||||
&paging.Page{
|
||||
Min: paging.EitherMinID(minID, sinceID),
|
||||
Max: paging.MaxID(maxID),
|
||||
Limit: limit,
|
||||
},
|
||||
|
||||
// Tag timeline name's endpoint.
|
||||
"/api/v1/timelines/tag/"+tagName,
|
||||
|
||||
// No page
|
||||
// query.
|
||||
nil,
|
||||
|
||||
// Status filter context.
|
||||
statusfilter.FilterContextPublic,
|
||||
|
||||
// Database load function.
|
||||
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
|
||||
return p.state.DB.GetTagTimeline(ctx, tag.ID, pg)
|
||||
},
|
||||
|
||||
// Filtering function,
|
||||
// i.e. filter before caching.
|
||||
func(s *gtsmodel.Status) bool {
|
||||
|
||||
// Check the visibility of passed status to requesting user.
|
||||
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
|
||||
}
|
||||
return !ok
|
||||
},
|
||||
|
||||
// Post filtering funtion,
|
||||
// i.e. filter after caching.
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -92,69 +130,3 @@ func (p *Processor) getTag(ctx context.Context, tagName string) (*gtsmodel.Tag,
|
|||
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
func (p *Processor) packageTagResponse(
|
||||
ctx context.Context,
|
||||
requestingAcct *gtsmodel.Account,
|
||||
statuses []*gtsmodel.Status,
|
||||
limit int,
|
||||
requestPath string,
|
||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
count := len(statuses)
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
items = make([]interface{}, 0, count)
|
||||
|
||||
// Set next + prev values before filtering and API
|
||||
// converting, so caller can still page properly.
|
||||
nextMaxIDValue = statuses[count-1].ID
|
||||
prevMinIDValue = statuses[0].ID
|
||||
)
|
||||
|
||||
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil)
|
||||
if err != nil {
|
||||
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
|
||||
|
||||
for _, s := range statuses {
|
||||
timelineable, err := p.visFilter.StatusTagTimelineable(ctx, requestingAcct, s)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error checking status visibility: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !timelineable {
|
||||
continue
|
||||
}
|
||||
|
||||
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, 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
|
||||
}
|
||||
|
||||
items = append(items, apiStatus)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: requestPath,
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,33 @@
|
|||
package timeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
timelinepkg "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/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
|
||||
)
|
||||
|
||||
var (
|
||||
// pre-prepared URL values to be passed in to
|
||||
// paging response forms. The paging package always
|
||||
// copies values before any modifications so it's
|
||||
// safe to only use a single map variable for these.
|
||||
localOnlyTrue = url.Values{"local": {"true"}}
|
||||
localOnlyFalse = url.Values{"local": {"false"}}
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
|
|
@ -36,3 +60,114 @@ func New(state *state.State, converter *typeutils.Converter, visFilter *visibili
|
|||
visFilter: visFilter,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) getStatusTimeline(
|
||||
ctx context.Context,
|
||||
requester *gtsmodel.Account,
|
||||
timeline *timelinepkg.StatusTimeline,
|
||||
page *paging.Page,
|
||||
pagePath string,
|
||||
pageQuery url.Values,
|
||||
filterCtx statusfilter.FilterContext,
|
||||
loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error),
|
||||
filter func(*gtsmodel.Status) (delete bool),
|
||||
postFilter func(*gtsmodel.Status) (remove bool),
|
||||
) (
|
||||
*apimodel.PageableResponse,
|
||||
gtserror.WithCode,
|
||||
) {
|
||||
var err error
|
||||
var filters []*gtsmodel.Filter
|
||||
var mutes *usermute.CompiledUserMuteList
|
||||
|
||||
if requester != nil {
|
||||
// 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, // 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)
|
||||
}
|
||||
|
||||
// Ensure we have valid
|
||||
// input paging cursor.
|
||||
id.ValidatePage(page)
|
||||
|
||||
// Load status page via timeline cache, also
|
||||
// getting lo, hi values for next, prev pages.
|
||||
//
|
||||
// NOTE: this safely handles the case of a nil
|
||||
// input timeline, i.e. uncached timeline type.
|
||||
apiStatuses, lo, hi, err := timeline.Load(ctx,
|
||||
|
||||
// Status page
|
||||
// to load.
|
||||
page,
|
||||
|
||||
// Caller provided database
|
||||
// status page loading function.
|
||||
loadPage,
|
||||
|
||||
// Status load function for cached timeline entries.
|
||||
func(ids []string) ([]*gtsmodel.Status, error) {
|
||||
return p.state.DB.GetStatusesByIDs(ctx, ids)
|
||||
},
|
||||
|
||||
// Call provided status
|
||||
// filtering function.
|
||||
filter,
|
||||
|
||||
// Frontend API model preparation function.
|
||||
func(status *gtsmodel.Status) (*apimodel.Status, error) {
|
||||
|
||||
// Check if status needs filtering OUTSIDE of caching stage.
|
||||
// TODO: this will be moved to separate postFilter hook when
|
||||
// all filtering has been removed from the type converter.
|
||||
if postFilter != nil && postFilter(status) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Finally, pass status to get converted to API model.
|
||||
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 {
|
||||
err := gtserror.Newf("error loading timeline: %w", err)
|
||||
return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
// Package returned API statuses as pageable response.
|
||||
return paging.PackageResponse(paging.ResponseParams{
|
||||
Items: xslices.ToAny(apiStatuses),
|
||||
Path: pagePath,
|
||||
Next: page.Next(lo, hi),
|
||||
Prev: page.Prev(lo, hi),
|
||||
Query: pageQuery,
|
||||
}), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ func (p *clientAPI) CreateStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -413,7 +413,7 @@ func (p *clientAPI) CreatePollVote(ctx context.Context, cMsg *messages.FromClien
|
|||
}
|
||||
|
||||
// Interaction counts changed on the source status, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, vote.Poll.StatusID)
|
||||
p.surface.invalidateStatusFromTimelines(vote.Poll.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -565,7 +565,7 @@ func (p *clientAPI) CreateLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID)
|
||||
p.surface.invalidateStatusFromTimelines(fave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -671,7 +671,7 @@ func (p *clientAPI) CreateAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -682,22 +682,20 @@ func (p *clientAPI) CreateBlock(ctx context.Context, cMsg *messages.FromClientAP
|
|||
return gtserror.Newf("%T not parseable as *gtsmodel.Block", cMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Remove blockee's statuses from blocker's timeline.
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
); err != nil {
|
||||
return gtserror.Newf("error wiping timeline items for block: %w", err)
|
||||
if block.Account.IsLocal() {
|
||||
// Remove posts by target from origin's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove blocker's statuses from blockee's timeline.
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
); err != nil {
|
||||
return gtserror.Newf("error wiping timeline items for block: %w", err)
|
||||
if block.TargetAccount.IsLocal() {
|
||||
// Remove posts by origin from target's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: same with notifications?
|
||||
|
|
@ -737,7 +735,7 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
}
|
||||
|
||||
// Status representation has changed, invalidate from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
p.surface.invalidateStatusFromTimelines(status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -858,6 +856,22 @@ func (p *clientAPI) UndoFollow(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
if follow.Account.IsLocal() {
|
||||
// Remove posts by target from origin's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
follow.AccountID,
|
||||
follow.TargetAccountID,
|
||||
)
|
||||
}
|
||||
|
||||
if follow.TargetAccount.IsLocal() {
|
||||
// Remove posts by origin from target's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
follow.TargetAccountID,
|
||||
follow.AccountID,
|
||||
)
|
||||
}
|
||||
|
||||
if err := p.federate.UndoFollow(ctx, follow); err != nil {
|
||||
log.Errorf(ctx, "error federating follow undo: %v", err)
|
||||
}
|
||||
|
|
@ -890,7 +904,7 @@ func (p *clientAPI) UndoFave(ctx context.Context, cMsg *messages.FromClientAPI)
|
|||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, statusFave.StatusID)
|
||||
p.surface.invalidateStatusFromTimelines(statusFave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -910,9 +924,8 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA
|
|||
log.Errorf(ctx, "error updating account stats: %v", err)
|
||||
}
|
||||
|
||||
if err := p.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil {
|
||||
log.Errorf(ctx, "error removing timelined status: %v", err)
|
||||
}
|
||||
// Delete the boost wrapper status from timelines.
|
||||
p.surface.deleteStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
if err := p.federate.UndoAnnounce(ctx, status); err != nil {
|
||||
log.Errorf(ctx, "error federating announce undo: %v", err)
|
||||
|
|
@ -920,7 +933,7 @@ func (p *clientAPI) UndoAnnounce(ctx context.Context, cMsg *messages.FromClientA
|
|||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(status.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -983,7 +996,7 @@ func (p *clientAPI) DeleteStatus(ctx context.Context, cMsg *messages.FromClientA
|
|||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -1026,6 +1039,23 @@ func (p *clientAPI) DeleteAccountOrUser(ctx context.Context, cMsg *messages.From
|
|||
p.state.Workers.Federator.Queue.Delete("Receiving.ID", account.ID)
|
||||
p.state.Workers.Federator.Queue.Delete("TargetURI", account.URI)
|
||||
|
||||
// Remove any entries authored by account from timelines.
|
||||
p.surface.removeTimelineEntriesByAccount(account.ID)
|
||||
|
||||
// Remove any of their cached timelines.
|
||||
p.state.Caches.Timelines.Home.Delete(account.ID)
|
||||
|
||||
// Get the IDs of all the lists owned by the given account ID.
|
||||
listIDs, err := p.state.DB.GetListIDsByAccountID(ctx, account.ID)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error getting lists for account %s: %v", account.ID, err)
|
||||
}
|
||||
|
||||
// Remove list timelines of account.
|
||||
for _, listID := range listIDs {
|
||||
p.state.Caches.Timelines.List.Delete(listID)
|
||||
}
|
||||
|
||||
if err := p.federate.DeleteAccount(ctx, cMsg.Target); err != nil {
|
||||
log.Errorf(ctx, "error federating account delete: %v", err)
|
||||
}
|
||||
|
|
@ -1169,7 +1199,7 @@ func (p *clientAPI) AcceptLike(ctx context.Context, cMsg *messages.FromClientAPI
|
|||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, req.Like.StatusID)
|
||||
p.surface.invalidateStatusFromTimelines(req.Like.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1202,7 +1232,7 @@ func (p *clientAPI) AcceptReply(ctx context.Context, cMsg *messages.FromClientAP
|
|||
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, reply.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(reply.InReplyToID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1240,7 +1270,7 @@ func (p *clientAPI) AcceptAnnounce(ctx context.Context, cMsg *messages.FromClien
|
|||
|
||||
// Interaction counts changed on the original status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,9 +197,22 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF
|
|||
// UNDO SOMETHING
|
||||
case ap.ActivityUndo:
|
||||
|
||||
switch fMsg.APObjectType {
|
||||
// UNDO FOLLOW
|
||||
case ap.ActivityFollow:
|
||||
return p.fediAPI.UndoFollow(ctx, fMsg)
|
||||
|
||||
// UNDO BLOCK
|
||||
case ap.ActivityBlock:
|
||||
return p.fediAPI.UndoBlock(ctx, fMsg)
|
||||
|
||||
// UNDO ANNOUNCE
|
||||
if fMsg.APObjectType == ap.ActivityAnnounce {
|
||||
case ap.ActivityAnnounce:
|
||||
return p.fediAPI.UndoAnnounce(ctx, fMsg)
|
||||
|
||||
// UNDO LIKE
|
||||
case ap.ActivityLike:
|
||||
return p.fediAPI.UndoFave(ctx, fMsg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,7 +359,7 @@ func (p *fediAPI) CreateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
// Interaction counts changed on the replied status; uncache the
|
||||
// prepared version from all timelines. The status dereferencer
|
||||
// functions will ensure necessary ancestors exist before this point.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -393,7 +406,7 @@ func (p *fediAPI) CreatePollVote(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
}
|
||||
|
||||
// Interaction counts changed, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
p.surface.invalidateStatusFromTimelines(status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -428,7 +441,7 @@ func (p *fediAPI) UpdatePollVote(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
}
|
||||
|
||||
// Interaction counts changed, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
p.surface.invalidateStatusFromTimelines(status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -573,7 +586,7 @@ func (p *fediAPI) CreateLike(ctx context.Context, fMsg *messages.FromFediAPI) er
|
|||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, fave.StatusID)
|
||||
p.surface.invalidateStatusFromTimelines(fave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -690,7 +703,7 @@ func (p *fediAPI) CreateAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
|
||||
// Interaction counts changed on the original status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -701,53 +714,32 @@ func (p *fediAPI) CreateBlock(ctx context.Context, fMsg *messages.FromFediAPI) e
|
|||
return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Remove each account's posts from the other's timelines.
|
||||
//
|
||||
// First home timelines.
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error wiping items from block -> target's home timeline: %v", err)
|
||||
if block.Account.IsLocal() {
|
||||
// Remove posts by target from origin's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
)
|
||||
}
|
||||
|
||||
if err := p.state.Timelines.Home.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error wiping items from target -> block's home timeline: %v", err)
|
||||
}
|
||||
|
||||
// Now list timelines.
|
||||
if err := p.state.Timelines.List.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error wiping items from block -> target's list timeline(s): %v", err)
|
||||
}
|
||||
|
||||
if err := p.state.Timelines.List.WipeItemsFromAccountID(
|
||||
ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error wiping items from target -> block's list timeline(s): %v", err)
|
||||
if block.TargetAccount.IsLocal() {
|
||||
// Remove posts by origin from target's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
)
|
||||
}
|
||||
|
||||
// Remove any follows that existed between blocker + blockee.
|
||||
if err := p.state.DB.DeleteFollow(
|
||||
ctx,
|
||||
// (note this handles removing any necessary list entries).
|
||||
if err := p.state.DB.DeleteFollow(ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error deleting follow from block -> target: %v", err)
|
||||
}
|
||||
|
||||
if err := p.state.DB.DeleteFollow(
|
||||
ctx,
|
||||
if err := p.state.DB.DeleteFollow(ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
); err != nil {
|
||||
|
|
@ -755,16 +747,14 @@ func (p *fediAPI) CreateBlock(ctx context.Context, fMsg *messages.FromFediAPI) e
|
|||
}
|
||||
|
||||
// Remove any follow requests that existed between blocker + blockee.
|
||||
if err := p.state.DB.DeleteFollowRequest(
|
||||
ctx,
|
||||
if err := p.state.DB.DeleteFollowRequest(ctx,
|
||||
block.AccountID,
|
||||
block.TargetAccountID,
|
||||
); err != nil {
|
||||
log.Errorf(ctx, "error deleting follow request from block -> target: %v", err)
|
||||
}
|
||||
|
||||
if err := p.state.DB.DeleteFollowRequest(
|
||||
ctx,
|
||||
if err := p.state.DB.DeleteFollowRequest(ctx,
|
||||
block.TargetAccountID,
|
||||
block.AccountID,
|
||||
); err != nil {
|
||||
|
|
@ -871,7 +861,7 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e
|
|||
|
||||
// Interaction counts changed on the replied-to status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -920,11 +910,11 @@ func (p *fediAPI) AcceptRemoteStatus(ctx context.Context, fMsg *messages.FromFed
|
|||
// Interaction counts changed on the interacted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
if status.InReplyToID != "" {
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
}
|
||||
|
||||
if status.BoostOfID != "" {
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(status.BoostOfID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -953,7 +943,7 @@ func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1004,7 +994,7 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
}
|
||||
|
||||
// Status representation was refetched, uncache from timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.ID)
|
||||
p.surface.invalidateStatusFromTimelines(status.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1063,7 +1053,7 @@ func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
if status.InReplyToID != "" {
|
||||
// Interaction counts changed on the replied status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
|
||||
p.surface.invalidateStatusFromTimelines(status.InReplyToID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -1090,6 +1080,9 @@ func (p *fediAPI) DeleteAccount(ctx context.Context, fMsg *messages.FromFediAPI)
|
|||
p.state.Workers.Federator.Queue.Delete("Requesting.ID", account.ID)
|
||||
p.state.Workers.Federator.Queue.Delete("TargetURI", account.URI)
|
||||
|
||||
// Remove any entries authored by account from timelines.
|
||||
p.surface.removeTimelineEntriesByAccount(account.ID)
|
||||
|
||||
// First perform the actual account deletion.
|
||||
if err := p.account.Delete(ctx, account, account.ID); err != nil {
|
||||
log.Errorf(ctx, "error deleting account: %v", err)
|
||||
|
|
@ -1208,6 +1201,42 @@ func (p *fediAPI) RejectAnnounce(ctx context.Context, fMsg *messages.FromFediAPI
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *fediAPI) UndoFollow(ctx context.Context, fMsg *messages.FromFediAPI) error {
|
||||
follow, ok := fMsg.GTSModel.(*gtsmodel.Follow)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.Follow", fMsg.GTSModel)
|
||||
}
|
||||
|
||||
if follow.Account.IsLocal() {
|
||||
// Remove posts by target from origin's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
follow.AccountID,
|
||||
follow.TargetAccountID,
|
||||
)
|
||||
}
|
||||
|
||||
if follow.TargetAccount.IsLocal() {
|
||||
// Remove posts by origin from target's timelines.
|
||||
p.surface.removeRelationshipFromTimelines(ctx,
|
||||
follow.TargetAccountID,
|
||||
follow.AccountID,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fediAPI) UndoBlock(ctx context.Context, fMsg *messages.FromFediAPI) error {
|
||||
_, ok := fMsg.GTSModel.(*gtsmodel.Block)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.Block", fMsg.GTSModel)
|
||||
}
|
||||
|
||||
// TODO: any required changes
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fediAPI) UndoAnnounce(
|
||||
ctx context.Context,
|
||||
fMsg *messages.FromFediAPI,
|
||||
|
|
@ -1228,13 +1257,24 @@ func (p *fediAPI) UndoAnnounce(
|
|||
}
|
||||
|
||||
// Remove the boost wrapper from all timelines.
|
||||
if err := p.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil {
|
||||
log.Errorf(ctx, "error removing timelined boost: %v", err)
|
||||
}
|
||||
p.surface.deleteStatusFromTimelines(ctx, boost.ID)
|
||||
|
||||
// Interaction counts changed on the boosted status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(ctx, boost.BoostOfID)
|
||||
p.surface.invalidateStatusFromTimelines(boost.BoostOfID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fediAPI) UndoFave(ctx context.Context, fMsg *messages.FromFediAPI) error {
|
||||
statusFave, ok := fMsg.GTSModel.(*gtsmodel.StatusFave)
|
||||
if !ok {
|
||||
return gtserror.Newf("%T not parseable as *gtsmodel.StatusFave", fMsg.GTSModel)
|
||||
}
|
||||
|
||||
// Interaction counts changed on the faved status;
|
||||
// uncache the prepared version from all timelines.
|
||||
p.surface.invalidateStatusFromTimelines(statusFave.StatusID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/cache/timeline"
|
||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
|
|
@ -28,7 +29,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/stream"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
|
|
@ -161,21 +161,16 @@ func (s *Surface) timelineAndNotifyStatusForFollowers(
|
|||
|
||||
// Add status to home timeline for owner of
|
||||
// this follow (origin account), if applicable.
|
||||
homeTimelined, err = s.timelineStatus(ctx,
|
||||
s.State.Timelines.Home.IngestOne,
|
||||
follow.AccountID, // home timelines are keyed by account ID
|
||||
if homeTimelined := s.timelineStatus(ctx,
|
||||
s.State.Caches.Timelines.Home.MustGet(follow.AccountID),
|
||||
follow.Account,
|
||||
status,
|
||||
stream.TimelineHome,
|
||||
statusfilter.FilterContextHome,
|
||||
filters,
|
||||
mutes,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error home timelining status: %v", err)
|
||||
continue
|
||||
}
|
||||
); homeTimelined {
|
||||
|
||||
if homeTimelined {
|
||||
// If hometimelined, add to list of returned account IDs.
|
||||
homeTimelinedAccountIDs = append(homeTimelinedAccountIDs, follow.AccountID)
|
||||
}
|
||||
|
|
@ -261,22 +256,16 @@ func (s *Surface) listTimelineStatusForFollow(
|
|||
exclusive = exclusive || *list.Exclusive
|
||||
|
||||
// At this point we are certain this status
|
||||
// should be included in the timeline of the
|
||||
// list that this list entry belongs to.
|
||||
listTimelined, err := s.timelineStatus(
|
||||
ctx,
|
||||
s.State.Timelines.List.IngestOne,
|
||||
list.ID, // list timelines are keyed by list ID
|
||||
// should be included in timeline of this list.
|
||||
listTimelined := s.timelineStatus(ctx,
|
||||
s.State.Caches.Timelines.List.MustGet(list.ID),
|
||||
follow.Account,
|
||||
status,
|
||||
stream.TimelineList+":"+list.ID, // key streamType to this specific list
|
||||
statusfilter.FilterContextHome,
|
||||
filters,
|
||||
mutes,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error adding status to list timeline: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update flag based on if timelined.
|
||||
timelined = timelined || listTimelined
|
||||
|
|
@ -367,53 +356,48 @@ func (s *Surface) listEligible(
|
|||
}
|
||||
}
|
||||
|
||||
// timelineStatus uses the provided ingest function to put the given
|
||||
// status in a timeline with the given ID, if it's timelineable.
|
||||
//
|
||||
// If the status was inserted into the timeline, true will be returned
|
||||
// + it will also be streamed to the user using the given streamType.
|
||||
// timelineStatus will insert the given status into the given timeline, if it's
|
||||
// timelineable. if the status was inserted into the timeline, true will be returned.
|
||||
func (s *Surface) timelineStatus(
|
||||
ctx context.Context,
|
||||
ingest func(context.Context, string, timeline.Timelineable) (bool, error),
|
||||
timelineID string,
|
||||
timeline *timeline.StatusTimeline,
|
||||
account *gtsmodel.Account,
|
||||
status *gtsmodel.Status,
|
||||
streamType string,
|
||||
filterCtx statusfilter.FilterContext,
|
||||
filters []*gtsmodel.Filter,
|
||||
mutes *usermute.CompiledUserMuteList,
|
||||
) (bool, error) {
|
||||
) bool {
|
||||
|
||||
// Ingest status into given timeline using provided function.
|
||||
if inserted, err := ingest(ctx, timelineID, status); err != nil &&
|
||||
!errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
err := gtserror.Newf("error ingesting status %s: %w", status.ID, err)
|
||||
return false, err
|
||||
} else if !inserted {
|
||||
// Nothing more to do.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Convert updated database model to frontend model.
|
||||
apiStatus, err := s.Converter.StatusToAPIStatus(ctx,
|
||||
// Attempt to convert status to frontend API representation,
|
||||
// this will check whether status is filtered / muted.
|
||||
apiModel, err := s.Converter.StatusToAPIStatus(ctx,
|
||||
status,
|
||||
account,
|
||||
statusfilter.FilterContextHome,
|
||||
filterCtx,
|
||||
filters,
|
||||
mutes,
|
||||
)
|
||||
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
err := gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
|
||||
return true, err
|
||||
log.Error(ctx, "error converting status %s to frontend: %v", status.URI, err)
|
||||
}
|
||||
|
||||
if apiStatus != nil {
|
||||
// The status was inserted so stream it to the user.
|
||||
s.Stream.Update(ctx, account, apiStatus, streamType)
|
||||
return true, nil
|
||||
// Insert status to timeline cache regardless of
|
||||
// if API model was succesfully prepared or not.
|
||||
repeatBoost := timeline.InsertOne(status, apiModel)
|
||||
|
||||
if apiModel == nil {
|
||||
// Status was
|
||||
// filtered / muted.
|
||||
return false
|
||||
}
|
||||
|
||||
// Status was hidden.
|
||||
return false, nil
|
||||
if !repeatBoost {
|
||||
// Only stream if not repeated boost of recent status.
|
||||
s.Stream.Update(ctx, account, apiModel, streamType)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// timelineAndNotifyStatusForTagFollowers inserts the status into the
|
||||
|
|
@ -444,23 +428,15 @@ func (s *Surface) timelineAndNotifyStatusForTagFollowers(
|
|||
continue
|
||||
}
|
||||
|
||||
if _, err := s.timelineStatus(
|
||||
ctx,
|
||||
s.State.Timelines.Home.IngestOne,
|
||||
tagFollowerAccount.ID, // home timelines are keyed by account ID
|
||||
_ = s.timelineStatus(ctx,
|
||||
s.State.Caches.Timelines.Home.MustGet(tagFollowerAccount.ID),
|
||||
tagFollowerAccount,
|
||||
status,
|
||||
stream.TimelineHome,
|
||||
statusfilter.FilterContextHome,
|
||||
filters,
|
||||
mutes,
|
||||
); err != nil {
|
||||
errs.Appendf(
|
||||
"error inserting status %s into home timeline for account %s: %w",
|
||||
status.ID,
|
||||
tagFollowerAccount.ID,
|
||||
err,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return errs.Combine()
|
||||
|
|
@ -550,39 +526,6 @@ func (s *Surface) tagFollowersForStatus(
|
|||
return visibleTagFollowerAccounts, errs.Combine()
|
||||
}
|
||||
|
||||
// deleteStatusFromTimelines completely removes the given status from all timelines.
|
||||
// It will also stream deletion of the status to all open streams.
|
||||
func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) error {
|
||||
if err := s.State.Timelines.Home.WipeItemFromAllTimelines(ctx, statusID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.State.Timelines.List.WipeItemFromAllTimelines(ctx, statusID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Stream.Delete(ctx, statusID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateStatusFromTimelines does cache invalidation on the given status by
|
||||
// unpreparing it from all timelines, forcing it to be prepared again (with updated
|
||||
// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes
|
||||
// both for the status itself, and for any boosts of the status.
|
||||
func (s *Surface) invalidateStatusFromTimelines(ctx context.Context, statusID string) {
|
||||
if err := s.State.Timelines.Home.UnprepareItemFromAllTimelines(ctx, statusID); err != nil {
|
||||
log.
|
||||
WithContext(ctx).
|
||||
WithField("statusID", statusID).
|
||||
Errorf("error unpreparing status from home timelines: %v", err)
|
||||
}
|
||||
|
||||
if err := s.State.Timelines.List.UnprepareItemFromAllTimelines(ctx, statusID); err != nil {
|
||||
log.
|
||||
WithContext(ctx).
|
||||
WithField("statusID", statusID).
|
||||
Errorf("error unpreparing status from list timelines: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// timelineStatusUpdate looks up HOME and LIST timelines of accounts
|
||||
// that follow the the status author or tags and pushes edit messages into any
|
||||
// active streams.
|
||||
|
|
@ -859,3 +802,47 @@ func (s *Surface) timelineStatusUpdateForTagFollowers(
|
|||
}
|
||||
return errs.Combine()
|
||||
}
|
||||
|
||||
// deleteStatusFromTimelines completely removes the given status from all timelines.
|
||||
// It will also stream deletion of the status to all open streams.
|
||||
func (s *Surface) deleteStatusFromTimelines(ctx context.Context, statusID string) {
|
||||
s.State.Caches.Timelines.Home.RemoveByStatusIDs(statusID)
|
||||
s.State.Caches.Timelines.List.RemoveByStatusIDs(statusID)
|
||||
s.Stream.Delete(ctx, statusID)
|
||||
}
|
||||
|
||||
// invalidateStatusFromTimelines does cache invalidation on the given status by
|
||||
// unpreparing it from all timelines, forcing it to be prepared again (with updated
|
||||
// stats, boost counts, etc) next time it's fetched by the timeline owner. This goes
|
||||
// both for the status itself, and for any boosts of the status.
|
||||
func (s *Surface) invalidateStatusFromTimelines(statusID string) {
|
||||
s.State.Caches.Timelines.Home.UnprepareByStatusIDs(statusID)
|
||||
s.State.Caches.Timelines.List.UnprepareByStatusIDs(statusID)
|
||||
}
|
||||
|
||||
// removeTimelineEntriesByAccount removes all cached timeline entries authored by account ID.
|
||||
func (s *Surface) removeTimelineEntriesByAccount(accountID string) {
|
||||
s.State.Caches.Timelines.Home.RemoveByAccountIDs(accountID)
|
||||
s.State.Caches.Timelines.List.RemoveByAccountIDs(accountID)
|
||||
}
|
||||
|
||||
func (s *Surface) removeRelationshipFromTimelines(ctx context.Context, timelineAccountID string, targetAccountID string) {
|
||||
// Remove all statuses by target account
|
||||
// from given account's home timeline.
|
||||
s.State.Caches.Timelines.Home.
|
||||
MustGet(timelineAccountID).
|
||||
RemoveByAccountIDs(targetAccountID)
|
||||
|
||||
// Get the IDs of all the lists owned by the given account ID.
|
||||
listIDs, err := s.State.DB.GetListIDsByAccountID(ctx, timelineAccountID)
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "error getting lists for account %s: %v", timelineAccountID, err)
|
||||
}
|
||||
|
||||
for _, listID := range listIDs {
|
||||
// Remove all statuses by target account
|
||||
// from given account's list timelines.
|
||||
s.State.Caches.Timelines.List.MustGet(listID).
|
||||
RemoveByAccountIDs(targetAccountID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,15 +172,11 @@ func (u *utils) wipeStatus(
|
|||
}
|
||||
|
||||
// Remove the boost from any and all timelines.
|
||||
if err := u.surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil {
|
||||
errs.Appendf("error deleting boost from timelines: %w", err)
|
||||
}
|
||||
u.surface.deleteStatusFromTimelines(ctx, boost.ID)
|
||||
}
|
||||
|
||||
// Delete the status itself from any and all timelines.
|
||||
if err := u.surface.deleteStatusFromTimelines(ctx, status.ID); err != nil {
|
||||
errs.Appendf("error deleting status from timelines: %w", err)
|
||||
}
|
||||
u.surface.deleteStatusFromTimelines(ctx, status.ID)
|
||||
|
||||
// Delete this status from any conversations it's part of.
|
||||
if err := u.state.DB.DeleteStatusFromConversations(ctx, status.ID); err != nil {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/cache"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/workers"
|
||||
)
|
||||
|
||||
|
|
@ -34,11 +33,10 @@ import (
|
|||
// subpackage initialization, while the returned subpackage type will later
|
||||
// then be set and stored within the State{} itself.
|
||||
type State struct {
|
||||
// Caches provides access to this state's collection of caches.
|
||||
Caches cache.Caches
|
||||
|
||||
// Timelines provides access to this state's collection of timelines.
|
||||
Timelines timeline.Timelines
|
||||
// Caches provides access to this
|
||||
// state's collection of caches.
|
||||
Caches cache.Caches
|
||||
|
||||
// DB provides access to the database.
|
||||
DB db.DB
|
||||
|
|
@ -59,7 +57,8 @@ type State struct {
|
|||
// pinned statuses, creating notifs, etc.
|
||||
ProcessingLocks mutexes.MutexMap
|
||||
|
||||
// Storage provides access to the storage driver.
|
||||
// Storage provides access
|
||||
// to the storage driver.
|
||||
Storage *storage.Driver
|
||||
|
||||
// Workers provides access to this
|
||||
|
|
|
|||
|
|
@ -1,428 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
func (t *timeline) LastGot() time.Time {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
return t.lastGot
|
||||
}
|
||||
|
||||
func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error) {
|
||||
l := log.WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"accountID", t.timelineID},
|
||||
{"amount", amount},
|
||||
{"maxID", maxID},
|
||||
{"sinceID", sinceID},
|
||||
{"minID", minID},
|
||||
}...)
|
||||
l.Trace("entering get and updating t.lastGot")
|
||||
|
||||
// Regardless of what happens below, update the
|
||||
// last time Get was called for this timeline.
|
||||
t.Lock()
|
||||
t.lastGot = time.Now()
|
||||
t.Unlock()
|
||||
|
||||
var (
|
||||
items []Preparable
|
||||
err error
|
||||
)
|
||||
|
||||
switch {
|
||||
case maxID == "" && sinceID == "" && minID == "":
|
||||
// No params are defined so just fetch from the top.
|
||||
// This is equivalent to a user starting to view
|
||||
// their timeline from newest -> older posts.
|
||||
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, id.Lowest, true)
|
||||
|
||||
// Cache expected next query to speed up scrolling.
|
||||
// Assume the user will be scrolling downwards from
|
||||
// the final ID in items.
|
||||
if prepareNext && err == nil && len(items) != 0 {
|
||||
nextMaxID := items[len(items)-1].GetID()
|
||||
t.prepareNextQuery(amount, nextMaxID, "", "")
|
||||
}
|
||||
|
||||
case maxID != "" && sinceID == "" && minID == "":
|
||||
// Only maxID is defined, so fetch from maxID onwards.
|
||||
// This is equivalent to a user paging further down
|
||||
// their timeline from newer -> older posts.
|
||||
items, err = t.getXBetweenIDs(ctx, amount, maxID, id.Lowest, true)
|
||||
|
||||
// Cache expected next query to speed up scrolling.
|
||||
// Assume the user will be scrolling downwards from
|
||||
// the final ID in items.
|
||||
if prepareNext && err == nil && len(items) != 0 {
|
||||
nextMaxID := items[len(items)-1].GetID()
|
||||
t.prepareNextQuery(amount, nextMaxID, "", "")
|
||||
}
|
||||
|
||||
// In the next cases, maxID is defined, and so are
|
||||
// either sinceID or minID. This is equivalent to
|
||||
// a user opening an in-progress timeline and asking
|
||||
// for a slice of posts somewhere in the middle, or
|
||||
// trying to "fill in the blanks" between two points,
|
||||
// paging either up or down.
|
||||
case maxID != "" && sinceID != "":
|
||||
items, err = t.getXBetweenIDs(ctx, amount, maxID, sinceID, true)
|
||||
|
||||
// Cache expected next query to speed up scrolling.
|
||||
// We can assume the caller is scrolling downwards.
|
||||
// Guess id.Lowest as sinceID, since we don't actually
|
||||
// know what the next sinceID would be.
|
||||
if prepareNext && err == nil && len(items) != 0 {
|
||||
nextMaxID := items[len(items)-1].GetID()
|
||||
t.prepareNextQuery(amount, nextMaxID, id.Lowest, "")
|
||||
}
|
||||
|
||||
case maxID != "" && minID != "":
|
||||
items, err = t.getXBetweenIDs(ctx, amount, maxID, minID, false)
|
||||
|
||||
// Cache expected next query to speed up scrolling.
|
||||
// We can assume the caller is scrolling upwards.
|
||||
// Guess id.Highest as maxID, since we don't actually
|
||||
// know what the next maxID would be.
|
||||
if prepareNext && err == nil && len(items) != 0 {
|
||||
prevMinID := items[0].GetID()
|
||||
t.prepareNextQuery(amount, id.Highest, "", prevMinID)
|
||||
}
|
||||
|
||||
// In the final cases, maxID is not defined, but
|
||||
// either sinceID or minID are. This is equivalent to
|
||||
// a user either "pulling up" at the top of their timeline
|
||||
// to refresh it and check if newer posts have come in, or
|
||||
// trying to scroll upwards from an old post to see what
|
||||
// they missed since then.
|
||||
//
|
||||
// In these calls, we use the highest possible ulid as
|
||||
// behindID because we don't have a cap for newest that
|
||||
// we're interested in.
|
||||
case maxID == "" && sinceID != "":
|
||||
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, sinceID, true)
|
||||
|
||||
// We can't cache an expected next query for this one,
|
||||
// since presumably the caller is at the top of their
|
||||
// timeline already.
|
||||
|
||||
case maxID == "" && minID != "":
|
||||
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, minID, false)
|
||||
|
||||
// Cache expected next query to speed up scrolling.
|
||||
// We can assume the caller is scrolling upwards.
|
||||
// Guess id.Highest as maxID, since we don't actually
|
||||
// know what the next maxID would be.
|
||||
if prepareNext && err == nil && len(items) != 0 {
|
||||
prevMinID := items[0].GetID()
|
||||
t.prepareNextQuery(amount, id.Highest, "", prevMinID)
|
||||
}
|
||||
|
||||
default:
|
||||
err = gtserror.New("switch statement exhausted with no results")
|
||||
}
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
// getXBetweenIDs returns x amount of items somewhere between (not including) the given IDs.
|
||||
//
|
||||
// If frontToBack is true, items will be served paging down from behindID.
|
||||
// This corresponds to an api call to /timelines/home?max_id=WHATEVER&since_id=WHATEVER
|
||||
//
|
||||
// If frontToBack is false, items will be served paging up from beforeID.
|
||||
// This corresponds to an api call to /timelines/home?max_id=WHATEVER&min_id=WHATEVER
|
||||
func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) ([]Preparable, error) {
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"amount", amount},
|
||||
{"behindID", behindID},
|
||||
{"beforeID", beforeID},
|
||||
{"frontToBack", frontToBack},
|
||||
}...)
|
||||
l.Trace("entering getXBetweenID")
|
||||
|
||||
// Assume length we need to return.
|
||||
items := make([]Preparable, 0, amount)
|
||||
|
||||
if beforeID >= behindID {
|
||||
// This is an impossible situation, we
|
||||
// can't serve anything between these.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Try to ensure we have enough items prepared.
|
||||
if err := t.prepareXBetweenIDs(ctx, amount, behindID, beforeID, frontToBack); err != nil {
|
||||
// An error here doesn't necessarily mean we
|
||||
// can't serve anything, so log + keep going.
|
||||
l.Debugf("error calling prepareXBetweenIDs: %s", err)
|
||||
}
|
||||
|
||||
var (
|
||||
beforeIDMark *list.Element
|
||||
served int
|
||||
// Our behavior while ranging through the
|
||||
// list changes depending on if we're
|
||||
// going front-to-back or back-to-front.
|
||||
//
|
||||
// To avoid checking which one we're doing
|
||||
// in each loop iteration, define our range
|
||||
// function here outside the loop.
|
||||
//
|
||||
// The bool indicates to the caller whether
|
||||
// iteration should continue (true) or stop
|
||||
// (false).
|
||||
rangeF func(e *list.Element) (bool, error)
|
||||
// If we get certain errors on entries as we're
|
||||
// looking through, we might want to cheekily
|
||||
// remove their elements from the timeline.
|
||||
// Everything added to this slice will be removed.
|
||||
removeElements = []*list.Element{}
|
||||
)
|
||||
|
||||
defer func() {
|
||||
for _, e := range removeElements {
|
||||
t.items.data.Remove(e)
|
||||
}
|
||||
}()
|
||||
|
||||
if frontToBack {
|
||||
// We're going front-to-back, which means we
|
||||
// don't need to look for a mark per se, we
|
||||
// just keep serving items until we've reached
|
||||
// a point where the items are out of the range
|
||||
// we're interested in.
|
||||
rangeF = func(e *list.Element) (bool, error) {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID >= behindID {
|
||||
// ID of this item is too high,
|
||||
// just keep iterating.
|
||||
l.Trace("item is too new, continuing")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if entry.itemID <= beforeID {
|
||||
// We've gone as far as we can through
|
||||
// the list and reached entries that are
|
||||
// now too old for us, stop here.
|
||||
l.Trace("reached older items, breaking")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
l.Trace("entry is just right")
|
||||
|
||||
if entry.prepared == nil {
|
||||
// Whoops, this entry isn't prepared yet; some
|
||||
// race condition? That's OK, we can do it now.
|
||||
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
|
||||
if err != nil {
|
||||
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
// This item has been filtered out by the requesting user's filters.
|
||||
// Remove it and skip past it.
|
||||
removeElements = append(removeElements, e)
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// ErrNoEntries means something has been deleted,
|
||||
// so we'll likely not be able to ever prepare this.
|
||||
// This means we can remove it and skip past it.
|
||||
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||
removeElements = append(removeElements, e)
|
||||
return true, nil
|
||||
}
|
||||
// We've got a proper db error.
|
||||
err = gtserror.Newf("db error while trying to prepare %s: %w", entry.itemID, err)
|
||||
return false, err
|
||||
}
|
||||
entry.prepared = prepared
|
||||
}
|
||||
|
||||
items = append(items, entry.prepared)
|
||||
|
||||
served++
|
||||
return served < amount, nil
|
||||
}
|
||||
} else {
|
||||
// Iterate through the list from the top, until
|
||||
// we reach an item with id smaller than beforeID;
|
||||
// ie., an item OLDER than beforeID. At that point,
|
||||
// we can stop looking because we're not interested
|
||||
// in older entries.
|
||||
rangeF = func(e *list.Element) (bool, error) {
|
||||
// Move the mark back one place each loop.
|
||||
beforeIDMark = e
|
||||
|
||||
if entry := e.Value.(*indexedItemsEntry); entry.itemID <= beforeID {
|
||||
// We've gone as far as we can through
|
||||
// the list and reached entries that are
|
||||
// now too old for us, stop here.
|
||||
l.Trace("reached older items, breaking")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through the list until the function
|
||||
// we defined above instructs us to stop.
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
keepGoing, err := rangeF(e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !keepGoing {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if frontToBack || beforeIDMark == nil {
|
||||
// If we're serving front to back, then
|
||||
// items should be populated by now. If
|
||||
// we're serving back to front but didn't
|
||||
// find any items newer than beforeID,
|
||||
// we can just return empty items.
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// We're serving back to front, so iterate upwards
|
||||
// towards the front of the list from the mark we found,
|
||||
// until we either get to the front, serve enough
|
||||
// items, or reach behindID.
|
||||
//
|
||||
// To preserve ordering, we need to reverse the slice
|
||||
// when we're finished.
|
||||
for e := beforeIDMark; e != nil; e = e.Prev() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID == beforeID {
|
||||
// Don't include the beforeID
|
||||
// entry itself, just continue.
|
||||
l.Trace("entry item ID is equal to beforeID, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.itemID >= behindID {
|
||||
// We've reached items that are
|
||||
// newer than what we're looking
|
||||
// for, just stop here.
|
||||
l.Trace("reached newer items, breaking")
|
||||
break
|
||||
}
|
||||
|
||||
if entry.prepared == nil {
|
||||
// Whoops, this entry isn't prepared yet; some
|
||||
// race condition? That's OK, we can do it now.
|
||||
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
|
||||
if err != nil {
|
||||
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
// This item has been filtered out by the requesting user's filters.
|
||||
// Remove it and skip past it.
|
||||
removeElements = append(removeElements, e)
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// ErrNoEntries means something has been deleted,
|
||||
// so we'll likely not be able to ever prepare this.
|
||||
// This means we can remove it and skip past it.
|
||||
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||
removeElements = append(removeElements, e)
|
||||
continue
|
||||
}
|
||||
// We've got a proper db error.
|
||||
err = gtserror.Newf("db error while trying to prepare %s: %w", entry.itemID, err)
|
||||
return nil, err
|
||||
}
|
||||
entry.prepared = prepared
|
||||
}
|
||||
|
||||
items = append(items, entry.prepared)
|
||||
|
||||
served++
|
||||
if served >= amount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse order of items.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
for l, r := 0, len(items)-1; l < r; l, r = l+1, r-1 {
|
||||
items[l], items[r] = items[r], items[l]
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) {
|
||||
var (
|
||||
// We explicitly use context.Background() rather than
|
||||
// accepting a context param because we don't want this
|
||||
// to stop/break when the calling context finishes.
|
||||
ctx = context.Background()
|
||||
err error
|
||||
)
|
||||
|
||||
// Always perform this async so caller doesn't have to wait.
|
||||
go func() {
|
||||
switch {
|
||||
case maxID == "" && sinceID == "" && minID == "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, id.Lowest, true)
|
||||
case maxID != "" && sinceID == "" && minID == "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, maxID, id.Lowest, true)
|
||||
case maxID != "" && sinceID != "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, maxID, sinceID, true)
|
||||
case maxID != "" && minID != "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, maxID, minID, false)
|
||||
case maxID == "" && sinceID != "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, sinceID, true)
|
||||
case maxID == "" && minID != "":
|
||||
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, minID, false)
|
||||
default:
|
||||
err = gtserror.New("switch statement exhausted with no results")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.
|
||||
WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"amount", amount},
|
||||
{"maxID", maxID},
|
||||
{"sinceID", sinceID},
|
||||
{"minID", minID},
|
||||
}...).
|
||||
Warnf("error preparing next query: %s", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -1,704 +0,0 @@
|
|||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||
)
|
||||
|
||||
type GetTestSuite struct {
|
||||
TimelineStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) checkStatuses(statuses []timeline.Preparable, maxID string, minID string, expectedLength int) {
|
||||
if l := len(statuses); l != expectedLength {
|
||||
suite.FailNow("", "expected %d statuses in slice, got %d", expectedLength, l)
|
||||
} else if l == 0 {
|
||||
// Can't test empty slice.
|
||||
return
|
||||
}
|
||||
|
||||
// Check ordering + bounds of statuses.
|
||||
highest := statuses[0].GetID()
|
||||
for _, status := range statuses {
|
||||
id := status.GetID()
|
||||
|
||||
if id >= maxID {
|
||||
suite.FailNow("", "%s greater than maxID %s", id, maxID)
|
||||
}
|
||||
|
||||
if id <= minID {
|
||||
suite.FailNow("", "%s smaller than minID %s", id, minID)
|
||||
}
|
||||
|
||||
if id > highest {
|
||||
suite.FailNow("", "statuses in slice were not ordered highest -> lowest ID")
|
||||
}
|
||||
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) emptyAccountFollows(ctx context.Context, accountID string) {
|
||||
// Get all of account's follows.
|
||||
follows, err := suite.state.DB.GetAccountFollows(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
accountID,
|
||||
nil, // select all
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Remove each follow.
|
||||
for _, follow := range follows {
|
||||
if err := suite.state.DB.DeleteFollowByID(ctx, follow.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no follows left.
|
||||
follows, err = suite.state.DB.GetAccountFollows(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
accountID,
|
||||
nil, // select all
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
if len(follows) != 0 {
|
||||
suite.FailNow("follows should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) emptyAccountStatuses(ctx context.Context, accountID string) {
|
||||
// Get all of account's statuses.
|
||||
statuses, err := suite.state.DB.GetAccountStatuses(
|
||||
ctx,
|
||||
accountID,
|
||||
9999,
|
||||
false,
|
||||
false,
|
||||
id.Highest,
|
||||
id.Lowest,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Remove each status.
|
||||
for _, status := range statuses {
|
||||
if err := suite.state.DB.DeleteStatusByID(ctx, status.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelinePageDown() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 5
|
||||
local = false
|
||||
)
|
||||
|
||||
// Get 5 from the top.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 5)
|
||||
|
||||
// Get 5 from next maxID.
|
||||
maxID = statuses[len(statuses)-1].GetID()
|
||||
statuses, err = suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, maxID, id.Lowest, 5)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelinePageUp() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = id.Lowest
|
||||
limit = 5
|
||||
local = false
|
||||
)
|
||||
|
||||
// Get 5 from the back.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 5)
|
||||
|
||||
// Page up from next minID.
|
||||
minID = statuses[0].GetID()
|
||||
statuses, err = suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 5)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossible() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 100
|
||||
local = false
|
||||
)
|
||||
|
||||
// Get 100 from the top.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 22)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossiblePageUp() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = id.Lowest
|
||||
limit = 100
|
||||
local = false
|
||||
)
|
||||
|
||||
// Get 100 from the back.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 22)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelineNoFollowing() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.emptyAccountFollows(ctx, testAccount.ID)
|
||||
|
||||
// Try to get 10 from the top of the timeline.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 9)
|
||||
|
||||
for _, s := range statuses {
|
||||
if s.GetAccountID() != testAccount.ID {
|
||||
suite.FailNow("timeline with no follows should only contain posts by timeline owner account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNewTimelineNoFollowingNoStatuses() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 5
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.emptyAccountFollows(ctx, testAccount.ID)
|
||||
suite.emptyAccountStatuses(ctx, testAccount.ID)
|
||||
|
||||
// Try to get 5 from the top of the timeline.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 0)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetNoParams() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Get 10 statuses from the top (no params).
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, id.Lowest, 10)
|
||||
|
||||
// First status should have the highest ID in the testrig.
|
||||
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetMaxID() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 10 with a max ID somewhere in the middle of the stack.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// We'll only get 6 statuses back.
|
||||
suite.checkStatuses(statuses, maxID, id.Lowest, 6)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetSinceID() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
minID = ""
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 10 with a since ID somewhere in the middle of the stack.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, sinceID, 10)
|
||||
|
||||
// The first status in the stack should have the highest ID of all
|
||||
// in the testrig, because we're paging down.
|
||||
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetSinceIDOneOnly() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
minID = ""
|
||||
limit = 1
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 1 with a since ID somewhere in the middle of the stack.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, sinceID, 1)
|
||||
|
||||
// The one status we got back should have the highest ID of all in
|
||||
// the testrig, because using sinceID means we're paging down.
|
||||
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetMinID() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
limit = 5
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 5 with a min ID somewhere in the middle of the stack.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 5)
|
||||
|
||||
// We're paging up so even the highest status ID in the pile
|
||||
// shouldn't be the highest ID we have.
|
||||
suite.NotEqual(suite.highestStatusID, statuses[0])
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetMinIDOneOnly() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
limit = 1
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 1 with a min ID somewhere in the middle of the stack.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||
|
||||
// The one status we got back should have the an ID equal to the
|
||||
// one ID immediately newer than it.
|
||||
suite.Equal("01F8MHC0H0A7XHTVH5F596ZKBM", statuses[0].GetID())
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetMinIDFromLowestInTestrig() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = suite.lowestStatusID
|
||||
limit = 1
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 1 with minID equal to the lowest status in the testrig.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||
|
||||
// The one status we got back should have an id higher than
|
||||
// the lowest status in the testrig, since minID is not inclusive.
|
||||
suite.Greater(statuses[0].GetID(), suite.lowestStatusID)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetMinIDFromLowestPossible() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = id.Lowest
|
||||
limit = 1
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 1 with the lowest possible min ID.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||
|
||||
// The one status we got back should have the an ID equal to the
|
||||
// lowest ID status in the test rig.
|
||||
suite.Equal(suite.lowestStatusID, statuses[0].GetID())
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetBetweenID() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = "01F8MHCP5P2NWYQ416SBA0XSEV"
|
||||
sinceID = ""
|
||||
minID = "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 10 between these two IDs
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// There's only two statuses between these two IDs.
|
||||
suite.checkStatuses(statuses, maxID, minID, 2)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetBetweenIDImpossible() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = id.Lowest
|
||||
sinceID = ""
|
||||
minID = id.Highest
|
||||
limit = 10
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Ask for 10 between these two IDs which present
|
||||
// an impossible query.
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// We should have nothing back.
|
||||
suite.checkStatuses(statuses, maxID, minID, 0)
|
||||
}
|
||||
|
||||
func (suite *GetTestSuite) TestGetTimelinesAsync() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
accountToNuke = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 5
|
||||
local = false
|
||||
multiplier = 5
|
||||
)
|
||||
|
||||
// Nuke one account's statuses and follows,
|
||||
// as though the account had just been created.
|
||||
suite.emptyAccountFollows(ctx, accountToNuke.ID)
|
||||
suite.emptyAccountStatuses(ctx, accountToNuke.ID)
|
||||
|
||||
// Get 5 statuses from each timeline in
|
||||
// our testrig at the same time, five times.
|
||||
wg := new(sync.WaitGroup)
|
||||
wg.Add(len(suite.testAccounts) * multiplier)
|
||||
|
||||
for i := 0; i < multiplier; i++ {
|
||||
go func() {
|
||||
for _, testAccount := range suite.testAccounts {
|
||||
if _, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
); err != nil {
|
||||
suite.Fail(err.Error())
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait() // Wait until all get calls have returned.
|
||||
}
|
||||
|
||||
func TestGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(GetTestSuite))
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
func (t *timeline) indexXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) error {
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"amount", amount},
|
||||
{"behindID", behindID},
|
||||
{"beforeID", beforeID},
|
||||
{"frontToBack", frontToBack},
|
||||
}...)
|
||||
l.Trace("entering indexXBetweenIDs")
|
||||
|
||||
if beforeID >= behindID {
|
||||
// This is an impossible situation, we
|
||||
// can't index anything between these.
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
// Lazily init indexed items.
|
||||
if t.items.data == nil {
|
||||
t.items.data = &list.List{}
|
||||
t.items.data.Init()
|
||||
}
|
||||
|
||||
// Start by mapping out the list so we know what
|
||||
// we have to do. Depending on the current state
|
||||
// of the list we might not have to do *anything*.
|
||||
var (
|
||||
position int
|
||||
listLen = t.items.data.Len()
|
||||
behindIDPosition int
|
||||
beforeIDPosition int
|
||||
)
|
||||
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
position++
|
||||
|
||||
if entry.itemID > behindID {
|
||||
l.Trace("item is too new, continuing")
|
||||
continue
|
||||
}
|
||||
|
||||
if behindIDPosition == 0 {
|
||||
// Gone far enough through the list
|
||||
// and found our behindID mark.
|
||||
// We only need to set this once.
|
||||
l.Tracef("found behindID mark %s at position %d", entry.itemID, position)
|
||||
behindIDPosition = position
|
||||
}
|
||||
|
||||
if entry.itemID >= beforeID {
|
||||
// Push the beforeID mark back
|
||||
// one place every iteration.
|
||||
l.Tracef("setting beforeID mark %s at position %d", entry.itemID, position)
|
||||
beforeIDPosition = position
|
||||
}
|
||||
|
||||
if entry.itemID <= beforeID {
|
||||
// We've gone beyond the bounds of
|
||||
// items we're interested in; stop.
|
||||
l.Trace("reached older items, breaking")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// We can now figure out if we need to make db calls.
|
||||
var grabMore bool
|
||||
switch {
|
||||
case listLen < amount:
|
||||
// The whole list is shorter than the
|
||||
// amount we're being asked to return,
|
||||
// make up the difference.
|
||||
grabMore = true
|
||||
amount -= listLen
|
||||
case beforeIDPosition-behindIDPosition < amount:
|
||||
// Not enough items between behindID and
|
||||
// beforeID to return amount required,
|
||||
// try to get more.
|
||||
grabMore = true
|
||||
}
|
||||
|
||||
if !grabMore {
|
||||
// We're good!
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch additional items.
|
||||
items, err := t.grab(ctx, amount, behindID, beforeID, frontToBack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Index all the items we got. We already have
|
||||
// a lock on the timeline, so don't call IndexOne
|
||||
// here, since that will also try to get a lock!
|
||||
for _, item := range items {
|
||||
entry := &indexedItemsEntry{
|
||||
itemID: item.GetID(),
|
||||
boostOfID: item.GetBoostOfID(),
|
||||
accountID: item.GetAccountID(),
|
||||
boostOfAccountID: item.GetBoostOfAccountID(),
|
||||
}
|
||||
|
||||
if _, err := t.items.insertIndexed(ctx, entry); err != nil {
|
||||
return gtserror.Newf("error inserting entry with itemID %s into index: %w", entry.itemID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// grab wraps the timeline's grabFunction in paging + filtering logic.
|
||||
func (t *timeline) grab(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) ([]Timelineable, error) {
|
||||
var (
|
||||
sinceID string
|
||||
minID string
|
||||
grabbed int
|
||||
maxID = behindID
|
||||
filtered = make([]Timelineable, 0, amount)
|
||||
)
|
||||
|
||||
if frontToBack {
|
||||
sinceID = beforeID
|
||||
} else {
|
||||
minID = beforeID
|
||||
}
|
||||
|
||||
for attempts := 0; attempts < 5; attempts++ {
|
||||
if grabbed >= amount {
|
||||
// We got everything we needed.
|
||||
break
|
||||
}
|
||||
|
||||
items, stop, err := t.grabFunction(
|
||||
ctx,
|
||||
t.timelineID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
// Don't grab more than we need to.
|
||||
amount-grabbed,
|
||||
)
|
||||
if err != nil {
|
||||
// Grab function already checks for
|
||||
// db.ErrNoEntries, so if an error
|
||||
// is returned then it's a real one.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stop || len(items) == 0 {
|
||||
// No items left.
|
||||
break
|
||||
}
|
||||
|
||||
// Set next query parameters.
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
maxID = items[len(items)-1].GetID()
|
||||
if maxID <= beforeID {
|
||||
// Can't go any further.
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Page up.
|
||||
minID = items[0].GetID()
|
||||
if minID >= behindID {
|
||||
// Can't go any further.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
ok, err := t.filterFunction(ctx, t.timelineID, item)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrNoEntries) {
|
||||
// Real error here.
|
||||
return nil, err
|
||||
}
|
||||
log.Warnf(ctx, "errNoEntries while filtering item %s: %s", item.GetID(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if ok {
|
||||
filtered = append(filtered, item)
|
||||
grabbed++ // count this as grabbed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
postIndexEntry := &indexedItemsEntry{
|
||||
itemID: statusID,
|
||||
boostOfID: boostOfID,
|
||||
accountID: accountID,
|
||||
boostOfAccountID: boostOfAccountID,
|
||||
}
|
||||
|
||||
if inserted, err := t.items.insertIndexed(ctx, postIndexEntry); err != nil {
|
||||
return false, gtserror.Newf("error inserting indexed: %w", err)
|
||||
} else if !inserted {
|
||||
// Entry wasn't inserted, so
|
||||
// don't bother preparing it.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
preparable, err := t.prepareFunction(ctx, t.timelineID, statusID)
|
||||
if err != nil {
|
||||
return true, gtserror.Newf("error preparing: %w", err)
|
||||
}
|
||||
postIndexEntry.prepared = preparable
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (t *timeline) Len() int {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
if t.items == nil || t.items.data == nil {
|
||||
// indexedItems hasnt been initialized yet.
|
||||
return 0
|
||||
}
|
||||
|
||||
return t.items.data.Len()
|
||||
}
|
||||
|
||||
func (t *timeline) OldestIndexedItemID() string {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
if t.items == nil || t.items.data == nil {
|
||||
// indexedItems hasnt been initialized yet.
|
||||
return ""
|
||||
}
|
||||
|
||||
e := t.items.data.Back()
|
||||
if e == nil {
|
||||
// List was empty.
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.Value.(*indexedItemsEntry).itemID
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
type IndexTestSuite struct {
|
||||
TimelineStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *IndexTestSuite) TestOldestIndexedItemIDEmpty() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
)
|
||||
|
||||
// the oldest indexed post should be an empty string since there's nothing indexed yet
|
||||
postID := suite.state.Timelines.Home.GetOldestIndexedID(ctx, testAccountID)
|
||||
suite.Empty(postID)
|
||||
|
||||
// indexLength should be 0
|
||||
suite.Zero(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
}
|
||||
|
||||
func (suite *IndexTestSuite) TestIndexAlreadyIndexed() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
testStatus = suite.testStatuses["local_account_1_status_1"]
|
||||
)
|
||||
|
||||
// index one post -- it should be indexed
|
||||
indexed, err := suite.state.Timelines.Home.IngestOne(ctx, testAccountID, testStatus)
|
||||
suite.NoError(err)
|
||||
suite.True(indexed)
|
||||
|
||||
// try to index the same post again -- it should not be indexed
|
||||
indexed, err = suite.state.Timelines.Home.IngestOne(ctx, testAccountID, testStatus)
|
||||
suite.NoError(err)
|
||||
suite.False(indexed)
|
||||
}
|
||||
|
||||
func (suite *IndexTestSuite) TestIndexBoostOfAlreadyIndexed() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
testStatus = suite.testStatuses["local_account_1_status_1"]
|
||||
boostOfTestStatus = >smodel.Status{
|
||||
CreatedAt: time.Now(),
|
||||
ID: "01FD4TA6G2Z6M7W8NJQ3K5WXYD",
|
||||
BoostOfID: testStatus.ID,
|
||||
AccountID: "01FD4TAY1C0NGEJVE9CCCX7QKS",
|
||||
BoostOfAccountID: testStatus.AccountID,
|
||||
}
|
||||
)
|
||||
|
||||
// index one post -- it should be indexed
|
||||
indexed, err := suite.state.Timelines.Home.IngestOne(ctx, testAccountID, testStatus)
|
||||
suite.NoError(err)
|
||||
suite.True(indexed)
|
||||
|
||||
// try to index the a boost of that post -- it should not be indexed
|
||||
indexed, err = suite.state.Timelines.Home.IngestOne(ctx, testAccountID, boostOfTestStatus)
|
||||
suite.NoError(err)
|
||||
suite.False(indexed)
|
||||
}
|
||||
|
||||
func TestIndexTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(IndexTestSuite))
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
type indexedItems struct {
|
||||
data *list.List
|
||||
skipInsert SkipInsertFunction
|
||||
}
|
||||
|
||||
type indexedItemsEntry struct {
|
||||
itemID string
|
||||
boostOfID string
|
||||
accountID string
|
||||
boostOfAccountID string
|
||||
prepared Preparable
|
||||
}
|
||||
|
||||
// WARNING: ONLY CALL THIS FUNCTION IF YOU ALREADY HAVE
|
||||
// A LOCK ON THE TIMELINE CONTAINING THIS INDEXEDITEMS!
|
||||
func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItemsEntry) (bool, error) {
|
||||
// Lazily init indexed items.
|
||||
if i.data == nil {
|
||||
i.data = &list.List{}
|
||||
i.data.Init()
|
||||
}
|
||||
|
||||
if i.data.Len() == 0 {
|
||||
// We have no entries yet, meaning this is both the
|
||||
// newest + oldest entry, so just put it in the front.
|
||||
i.data.PushFront(newEntry)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var (
|
||||
insertMark *list.Element
|
||||
currentPosition int
|
||||
)
|
||||
|
||||
// We need to iterate through the index to make sure we put
|
||||
// this item in the appropriate place according to its id.
|
||||
// We also need to make sure we're not inserting a duplicate
|
||||
// item -- this can happen sometimes and it's sucky UX.
|
||||
for e := i.data.Front(); e != nil; e = e.Next() {
|
||||
currentPosition++
|
||||
|
||||
currentEntry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
// Check if we need to skip inserting this item based on
|
||||
// the current item.
|
||||
//
|
||||
// For example, if the new item is a boost, and the current
|
||||
// item is the original, we may not want to insert the boost
|
||||
// if it would appear very shortly after the original.
|
||||
if skip, err := i.skipInsert(
|
||||
ctx,
|
||||
newEntry.itemID,
|
||||
newEntry.accountID,
|
||||
newEntry.boostOfID,
|
||||
newEntry.boostOfAccountID,
|
||||
currentEntry.itemID,
|
||||
currentEntry.accountID,
|
||||
currentEntry.boostOfID,
|
||||
currentEntry.boostOfAccountID,
|
||||
currentPosition,
|
||||
); err != nil {
|
||||
return false, gtserror.Newf("error calling skipInsert: %w", err)
|
||||
} else if skip {
|
||||
// We don't need to insert this at all,
|
||||
// so we can safely bail.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if insertMark != nil {
|
||||
// We already found our mark.
|
||||
continue
|
||||
}
|
||||
|
||||
if currentEntry.itemID > newEntry.itemID {
|
||||
// We're still in items newer than
|
||||
// the one we're trying to insert.
|
||||
continue
|
||||
}
|
||||
|
||||
// We found our spot!
|
||||
insertMark = e
|
||||
}
|
||||
|
||||
if insertMark == nil {
|
||||
// We looked through the whole timeline and didn't find
|
||||
// a mark, so the new item is the oldest item we've seen;
|
||||
// insert it at the back.
|
||||
i.data.PushBack(newEntry)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
i.data.InsertBefore(newEntry, insertMark)
|
||||
return true, nil
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
// 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"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
const (
|
||||
pruneLengthIndexed = 400
|
||||
pruneLengthPrepared = 50
|
||||
)
|
||||
|
||||
// Manager abstracts functions for creating multiple timelines, and adding, removing, and fetching entries from those timelines.
|
||||
//
|
||||
// By the time a timelineable hits the manager interface, it should already have been filtered and it should be established that the item indeed
|
||||
// belongs in the given timeline.
|
||||
//
|
||||
// The manager makes a distinction between *indexed* items and *prepared* items.
|
||||
//
|
||||
// Indexed items consist of just that item's ID (in the database) and the time it was created. An indexed item takes up very little memory, so
|
||||
// it's not a huge priority to keep trimming the indexed items list.
|
||||
//
|
||||
// Prepared items consist of the item's database ID, the time it was created, AND the apimodel representation of that item, for quick serialization.
|
||||
// Prepared items of course take up more memory than indexed items, so they should be regularly pruned if they're not being actively served.
|
||||
type Manager interface {
|
||||
// IngestOne takes one timelineable and indexes it into the given timeline, and then immediately prepares it for serving.
|
||||
// This is useful in cases where we know the item will need to be shown at the top of a user's timeline immediately (eg., a new status is created).
|
||||
//
|
||||
// It should already be established before calling this function that the item actually belongs in the timeline!
|
||||
//
|
||||
// The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
|
||||
// a status is a boost, but a boost of the original status or the status itself already exists recently in the timeline.
|
||||
IngestOne(ctx context.Context, timelineID string, item Timelineable) (bool, error)
|
||||
|
||||
// GetTimeline returns limit n amount of prepared entries from the given timeline, in descending chronological order.
|
||||
GetTimeline(ctx context.Context, timelineID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error)
|
||||
|
||||
// GetIndexedLength returns the amount of items that have been indexed for the given account ID.
|
||||
GetIndexedLength(ctx context.Context, timelineID string) int
|
||||
|
||||
// GetOldestIndexedID returns the id ID for the oldest item that we have indexed for the given timeline.
|
||||
// Will be an empty string if nothing is (yet) indexed.
|
||||
GetOldestIndexedID(ctx context.Context, timelineID string) string
|
||||
|
||||
// Remove removes one item from the given timeline.
|
||||
Remove(ctx context.Context, timelineID string, itemID string) (int, error)
|
||||
|
||||
// RemoveTimeline completely removes one timeline.
|
||||
RemoveTimeline(ctx context.Context, timelineID string) error
|
||||
|
||||
// WipeItemFromAllTimelines removes one item from the index and prepared items of all timelines
|
||||
WipeItemFromAllTimelines(ctx context.Context, itemID string) error
|
||||
|
||||
// WipeStatusesFromAccountID removes all items by the given accountID from the given timeline.
|
||||
WipeItemsFromAccountID(ctx context.Context, timelineID string, accountID string) error
|
||||
|
||||
// UnprepareItem unprepares/uncaches the prepared version fo the given itemID from the given timelineID.
|
||||
// Use this for cache invalidation when the prepared representation of an item has changed.
|
||||
UnprepareItem(ctx context.Context, timelineID string, itemID string) error
|
||||
|
||||
// UnprepareItemFromAllTimelines unprepares/uncaches the prepared version of the given itemID from all timelines.
|
||||
// Use this for cache invalidation when the prepared representation of an item has changed.
|
||||
UnprepareItemFromAllTimelines(ctx context.Context, itemID string) error
|
||||
|
||||
// Prune manually triggers a prune operation for the given timelineID.
|
||||
Prune(ctx context.Context, timelineID string, desiredPreparedItemsLength int, desiredIndexedItemsLength int) (int, error)
|
||||
|
||||
// Start starts hourly cleanup jobs for this timeline manager.
|
||||
Start() error
|
||||
|
||||
// Stop stops the timeline manager (currently a stub, doesn't do anything).
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// NewManager returns a new timeline manager.
|
||||
func NewManager(grabFunction GrabFunction, filterFunction FilterFunction, prepareFunction PrepareFunction, skipInsertFunction SkipInsertFunction) Manager {
|
||||
return &manager{
|
||||
timelines: sync.Map{},
|
||||
grabFunction: grabFunction,
|
||||
filterFunction: filterFunction,
|
||||
prepareFunction: prepareFunction,
|
||||
skipInsertFunction: skipInsertFunction,
|
||||
}
|
||||
}
|
||||
|
||||
type manager struct {
|
||||
timelines sync.Map
|
||||
grabFunction GrabFunction
|
||||
filterFunction FilterFunction
|
||||
prepareFunction PrepareFunction
|
||||
skipInsertFunction SkipInsertFunction
|
||||
}
|
||||
|
||||
func (m *manager) Start() error {
|
||||
// Start a background goroutine which iterates
|
||||
// through all stored timelines once per hour,
|
||||
// and cleans up old entries if that timeline
|
||||
// hasn't been accessed in the last hour.
|
||||
go func() {
|
||||
for now := range time.NewTicker(1 * time.Hour).C {
|
||||
now := now // rescope
|
||||
// Define the range function inside here,
|
||||
// so that we can use the 'now' returned
|
||||
// by the ticker, instead of having to call
|
||||
// time.Now() multiple times.
|
||||
//
|
||||
// Unless it panics, this function always
|
||||
// returns 'true', to continue the Range
|
||||
// call through the sync.Map.
|
||||
f := func(_ any, v any) bool {
|
||||
timeline, ok := v.(Timeline)
|
||||
if !ok {
|
||||
log.Panic(nil, "couldn't parse timeline manager sync map value as Timeline, this should never happen so panic")
|
||||
}
|
||||
|
||||
if now.Sub(timeline.LastGot()) < 1*time.Hour {
|
||||
// Timeline has been fetched in the
|
||||
// last hour, move on to the next one.
|
||||
return true
|
||||
}
|
||||
|
||||
if amountPruned := timeline.Prune(pruneLengthPrepared, pruneLengthIndexed); amountPruned > 0 {
|
||||
log.WithField("accountID", timeline.TimelineID()).Infof("pruned %d indexed and prepared items from timeline", amountPruned)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Execute the function for each timeline.
|
||||
m.timelines.Range(f)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) IngestOne(ctx context.Context, timelineID string, item Timelineable) (bool, error) {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).IndexAndPrepareOne(
|
||||
ctx,
|
||||
item.GetID(),
|
||||
item.GetBoostOfID(),
|
||||
item.GetAccountID(),
|
||||
item.GetBoostOfAccountID(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *manager) Remove(ctx context.Context, timelineID string, itemID string) (int, error) {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).Remove(ctx, itemID)
|
||||
}
|
||||
|
||||
func (m *manager) RemoveTimeline(ctx context.Context, timelineID string) error {
|
||||
m.timelines.Delete(timelineID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) GetTimeline(ctx context.Context, timelineID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).Get(ctx, limit, maxID, sinceID, minID, true)
|
||||
}
|
||||
|
||||
func (m *manager) GetIndexedLength(ctx context.Context, timelineID string) int {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).Len()
|
||||
}
|
||||
|
||||
func (m *manager) GetOldestIndexedID(ctx context.Context, timelineID string) string {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).OldestIndexedItemID()
|
||||
}
|
||||
|
||||
func (m *manager) WipeItemFromAllTimelines(ctx context.Context, itemID string) error {
|
||||
errs := new(gtserror.MultiError)
|
||||
|
||||
m.timelines.Range(func(_ any, v any) bool {
|
||||
if _, err := v.(Timeline).Remove(ctx, itemID); err != nil {
|
||||
errs.Append(err)
|
||||
}
|
||||
|
||||
return true // always continue range
|
||||
})
|
||||
|
||||
if err := errs.Combine(); err != nil {
|
||||
return gtserror.Newf("error(s) wiping status %s: %w", itemID, errs.Combine())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) WipeItemsFromAccountID(ctx context.Context, timelineID string, accountID string) error {
|
||||
_, err := m.getOrCreateTimeline(ctx, timelineID).RemoveAllByOrBoosting(ctx, accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *manager) UnprepareItemFromAllTimelines(ctx context.Context, itemID string) error {
|
||||
errs := new(gtserror.MultiError)
|
||||
|
||||
// Work through all timelines held by this
|
||||
// manager, and call Unprepare for each.
|
||||
m.timelines.Range(func(_ any, v any) bool {
|
||||
if err := v.(Timeline).Unprepare(ctx, itemID); err != nil {
|
||||
errs.Append(err)
|
||||
}
|
||||
|
||||
return true // always continue range
|
||||
})
|
||||
|
||||
if err := errs.Combine(); err != nil {
|
||||
return gtserror.Newf("error(s) unpreparing status %s: %w", itemID, errs.Combine())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *manager) UnprepareItem(ctx context.Context, timelineID string, itemID string) error {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).Unprepare(ctx, itemID)
|
||||
}
|
||||
|
||||
func (m *manager) Prune(ctx context.Context, timelineID string, desiredPreparedItemsLength int, desiredIndexedItemsLength int) (int, error) {
|
||||
return m.getOrCreateTimeline(ctx, timelineID).Prune(desiredPreparedItemsLength, desiredIndexedItemsLength), nil
|
||||
}
|
||||
|
||||
// getOrCreateTimeline returns a timeline with the given id,
|
||||
// creating a new timeline with that id if necessary.
|
||||
func (m *manager) getOrCreateTimeline(ctx context.Context, timelineID string) Timeline {
|
||||
i, ok := m.timelines.Load(timelineID)
|
||||
if ok {
|
||||
// Timeline already existed in sync.Map.
|
||||
return i.(Timeline)
|
||||
}
|
||||
|
||||
// Timeline did not yet exist in sync.Map.
|
||||
// Create + store it.
|
||||
timeline := NewTimeline(ctx, timelineID, m.grabFunction, m.filterFunction, m.prepareFunction, m.skipInsertFunction)
|
||||
m.timelines.Store(timelineID, timeline)
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) error {
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"amount", amount},
|
||||
{"behindID", behindID},
|
||||
{"beforeID", beforeID},
|
||||
{"frontToBack", frontToBack},
|
||||
}...)
|
||||
l.Trace("entering prepareXBetweenIDs")
|
||||
|
||||
if beforeID >= behindID {
|
||||
// This is an impossible situation, we
|
||||
// can't prepare anything between these.
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := t.indexXBetweenIDs(ctx, amount, behindID, beforeID, frontToBack); err != nil {
|
||||
// An error here doesn't necessarily mean we
|
||||
// can't prepare anything, so log + keep going.
|
||||
l.Debugf("error calling prepareXBetweenIDs: %s", err)
|
||||
}
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
// Try to prepare everything between (and including) the two points.
|
||||
var (
|
||||
toPrepare = make(map[*list.Element]*indexedItemsEntry)
|
||||
foundToPrepare int
|
||||
)
|
||||
|
||||
if frontToBack {
|
||||
// Paging forwards / down.
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID > behindID {
|
||||
l.Trace("item is too new, continuing")
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.itemID < beforeID {
|
||||
// We've gone beyond the bounds of
|
||||
// items we're interested in; stop.
|
||||
l.Trace("reached older items, breaking")
|
||||
break
|
||||
}
|
||||
|
||||
// Only prepare entry if it's not
|
||||
// already prepared, save db calls.
|
||||
if entry.prepared == nil {
|
||||
toPrepare[e] = entry
|
||||
}
|
||||
|
||||
foundToPrepare++
|
||||
if foundToPrepare >= amount {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Paging backwards / up.
|
||||
for e := t.items.data.Back(); e != nil; e = e.Prev() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID < beforeID {
|
||||
l.Trace("item is too old, continuing")
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.itemID > behindID {
|
||||
// We've gone beyond the bounds of
|
||||
// items we're interested in; stop.
|
||||
l.Trace("reached newer items, breaking")
|
||||
break
|
||||
}
|
||||
|
||||
if entry.prepared == nil {
|
||||
toPrepare[e] = entry
|
||||
}
|
||||
|
||||
// Only prepare entry if it's not
|
||||
// already prepared, save db calls.
|
||||
foundToPrepare++
|
||||
if foundToPrepare >= amount {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for e, entry := range toPrepare {
|
||||
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
|
||||
if err != nil {
|
||||
if errors.Is(err, statusfilter.ErrHideStatus) {
|
||||
// This item has been filtered out by the requesting user's filters.
|
||||
// Remove it and skip past it.
|
||||
t.items.data.Remove(e)
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// ErrNoEntries means something has been deleted,
|
||||
// so we'll likely not be able to ever prepare this.
|
||||
// This means we can remove it and skip past it.
|
||||
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||
t.items.data.Remove(e)
|
||||
continue
|
||||
}
|
||||
// We've got a proper db error.
|
||||
return gtserror.Newf("db error while trying to prepare %s: %w", entry.itemID, err)
|
||||
}
|
||||
entry.prepared = prepared
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
)
|
||||
|
||||
func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
l := t.items.data
|
||||
if l == nil {
|
||||
// Nothing to prune.
|
||||
return 0
|
||||
}
|
||||
|
||||
var (
|
||||
position int
|
||||
totalPruned int
|
||||
toRemove *[]*list.Element
|
||||
)
|
||||
|
||||
// Only initialize toRemove if we know we're
|
||||
// going to need it, otherwise skiperino.
|
||||
if toRemoveLen := t.items.data.Len() - desiredIndexedItemsLength; toRemoveLen > 0 {
|
||||
toRemove = func() *[]*list.Element { tr := make([]*list.Element, 0, toRemoveLen); return &tr }()
|
||||
}
|
||||
|
||||
// Work from the front of the list until we get
|
||||
// to the point where we need to start pruning.
|
||||
for e := l.Front(); e != nil; e = e.Next() {
|
||||
position++
|
||||
|
||||
if position <= desiredPreparedItemsLength {
|
||||
// We're still within our allotted
|
||||
// prepped length, nothing to do yet.
|
||||
continue
|
||||
}
|
||||
|
||||
// We need to *at least* unprepare this entry.
|
||||
// If we're beyond our indexed length already,
|
||||
// we can just remove the item completely.
|
||||
if position > desiredIndexedItemsLength {
|
||||
*toRemove = append(*toRemove, e)
|
||||
totalPruned++
|
||||
continue
|
||||
}
|
||||
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
if entry.prepared == nil {
|
||||
// It's already unprepared (mood).
|
||||
continue
|
||||
}
|
||||
|
||||
entry.prepared = nil // <- eat this up please garbage collector nom nom nom
|
||||
totalPruned++
|
||||
}
|
||||
|
||||
if toRemove != nil {
|
||||
for _, e := range *toRemove {
|
||||
l.Remove(e)
|
||||
}
|
||||
}
|
||||
|
||||
return totalPruned
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type PruneTestSuite struct {
|
||||
TimelineStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *PruneTestSuite) TestPrune() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
desiredPreparedItemsLength = 5
|
||||
desiredIndexedItemsLength = 5
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccountID)
|
||||
|
||||
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
|
||||
suite.NoError(err)
|
||||
suite.Equal(25, pruned)
|
||||
suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
}
|
||||
|
||||
func (suite *PruneTestSuite) TestPruneTwice() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
desiredPreparedItemsLength = 5
|
||||
desiredIndexedItemsLength = 5
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccountID)
|
||||
|
||||
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
|
||||
suite.NoError(err)
|
||||
suite.Equal(25, pruned)
|
||||
suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
|
||||
// Prune same again, nothing should be pruned this time.
|
||||
pruned, err = suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
|
||||
suite.NoError(err)
|
||||
suite.Equal(0, pruned)
|
||||
suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
}
|
||||
|
||||
func (suite *PruneTestSuite) TestPruneTo0() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
desiredPreparedItemsLength = 0
|
||||
desiredIndexedItemsLength = 0
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccountID)
|
||||
|
||||
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
|
||||
suite.NoError(err)
|
||||
suite.Equal(30, pruned)
|
||||
suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
}
|
||||
|
||||
func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccountID = suite.testAccounts["local_account_1"].ID
|
||||
desiredPreparedItemsLength = 9999999
|
||||
desiredIndexedItemsLength = 9999999
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccountID)
|
||||
|
||||
pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength)
|
||||
suite.NoError(err)
|
||||
suite.Equal(0, pruned)
|
||||
suite.Equal(30, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID))
|
||||
}
|
||||
|
||||
func TestPruneTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(PruneTestSuite))
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
// 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 (
|
||||
"container/list"
|
||||
"context"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
|
||||
l := log.WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"accountTimeline", t.timelineID},
|
||||
{"statusID", statusID},
|
||||
}...)
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
if t.items == nil || t.items.data == nil {
|
||||
// Nothing to do.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var toRemove []*list.Element
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.itemID != statusID {
|
||||
// Not relevant.
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debug("removing item")
|
||||
toRemove = append(toRemove, e)
|
||||
}
|
||||
|
||||
for _, e := range toRemove {
|
||||
t.items.data.Remove(e)
|
||||
}
|
||||
|
||||
return len(toRemove), nil
|
||||
}
|
||||
|
||||
func (t *timeline) RemoveAllByOrBoosting(ctx context.Context, accountID string) (int, error) {
|
||||
l := log.
|
||||
WithContext(ctx).
|
||||
WithFields(kv.Fields{
|
||||
{"accountTimeline", t.timelineID},
|
||||
{"accountID", accountID},
|
||||
}...)
|
||||
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
if t.items == nil || t.items.data == nil {
|
||||
// Nothing to do.
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var toRemove []*list.Element
|
||||
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||
entry := e.Value.(*indexedItemsEntry)
|
||||
|
||||
if entry.accountID != accountID && entry.boostOfAccountID != accountID {
|
||||
// Not relevant.
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debug("removing item")
|
||||
toRemove = append(toRemove, e)
|
||||
}
|
||||
|
||||
for _, e := range toRemove {
|
||||
t.items.data.Remove(e)
|
||||
}
|
||||
|
||||
return len(toRemove), nil
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
// 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"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GrabFunction is used by a Timeline to grab more items to index.
|
||||
//
|
||||
// It should be provided to NewTimeline when the caller is creating a timeline
|
||||
// (of statuses, notifications, etc).
|
||||
//
|
||||
// - timelineID: ID of the timeline.
|
||||
// - maxID: the maximum item ID desired.
|
||||
// - sinceID: the minimum item ID desired.
|
||||
// - minID: see sinceID
|
||||
// - limit: the maximum amount of items to be returned
|
||||
//
|
||||
// If an error is returned, the timeline will stop processing whatever request called GrabFunction,
|
||||
// and return the error. If no error is returned, but stop = true, this indicates to the caller of GrabFunction
|
||||
// that there are no more items to return, and processing should continue with the items already grabbed.
|
||||
type GrabFunction func(ctx context.Context, timelineID string, maxID string, sinceID string, minID string, limit int) (items []Timelineable, stop bool, err error)
|
||||
|
||||
// FilterFunction is used by a Timeline to filter whether or not a grabbed item should be indexed.
|
||||
type FilterFunction func(ctx context.Context, timelineID string, item Timelineable) (shouldIndex bool, err error)
|
||||
|
||||
// PrepareFunction converts a Timelineable into a Preparable.
|
||||
//
|
||||
// For example, this might result in the converstion of a *gtsmodel.Status with the given itemID into a serializable *apimodel.Status.
|
||||
type PrepareFunction func(ctx context.Context, timelineID string, itemID string) (Preparable, error)
|
||||
|
||||
// SkipInsertFunction indicates whether a new item about to be inserted in the prepared list should be skipped,
|
||||
// based on the item itself, the next item in the timeline, and the depth at which nextItem has been found in the list.
|
||||
//
|
||||
// This will be called for every item found while iterating through a timeline, so callers should be very careful
|
||||
// not to do anything expensive here.
|
||||
type SkipInsertFunction func(ctx context.Context,
|
||||
newItemID string,
|
||||
newItemAccountID string,
|
||||
newItemBoostOfID string,
|
||||
newItemBoostOfAccountID string,
|
||||
nextItemID string,
|
||||
nextItemAccountID string,
|
||||
nextItemBoostOfID string,
|
||||
nextItemBoostOfAccountID string,
|
||||
depth int) (bool, error)
|
||||
|
||||
// Timeline represents a timeline for one account, and contains indexed and prepared items.
|
||||
type Timeline interface {
|
||||
/*
|
||||
RETRIEVAL FUNCTIONS
|
||||
*/
|
||||
|
||||
// Get returns an amount of prepared items with the given parameters.
|
||||
// If prepareNext is true, then the next predicted query will be prepared already in a goroutine,
|
||||
// to make the next call to Get faster.
|
||||
Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error)
|
||||
|
||||
/*
|
||||
INDEXING + PREPARATION FUNCTIONS
|
||||
*/
|
||||
|
||||
// IndexAndPrepareOne puts a item into the timeline at the appropriate place
|
||||
// according to its id, and then immediately prepares it.
|
||||
//
|
||||
// The returned bool indicates whether or not the item was actually inserted
|
||||
// into the timeline. This will be false if the item is a boost and the original
|
||||
// item, or a boost of it, already exists recently in the timeline.
|
||||
IndexAndPrepareOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error)
|
||||
|
||||
// Unprepare clears the prepared version of the given item (and any boosts
|
||||
// thereof) from the timeline, but leaves the indexed version in place.
|
||||
//
|
||||
// This is useful for cache invalidation when the prepared version of the
|
||||
// item has changed for some reason (edits, updates, etc), but the item does
|
||||
// not need to be removed: it will be prepared again next time Get is called.
|
||||
Unprepare(ctx context.Context, itemID string) error
|
||||
|
||||
/*
|
||||
INFO FUNCTIONS
|
||||
*/
|
||||
|
||||
// TimelineID returns the id of this timeline.
|
||||
TimelineID() string
|
||||
|
||||
// Len returns the length of the item index at this point in time.
|
||||
Len() int
|
||||
|
||||
// OldestIndexedItemID returns the id of the rearmost (ie., the oldest) indexed item.
|
||||
// If there's no oldest item, an empty string will be returned so make sure to check for this.
|
||||
OldestIndexedItemID() string
|
||||
|
||||
/*
|
||||
UTILITY FUNCTIONS
|
||||
*/
|
||||
|
||||
// LastGot returns the time that Get was last called.
|
||||
LastGot() time.Time
|
||||
|
||||
// Prune prunes prepared and indexed items in this timeline to the desired lengths.
|
||||
// This will be a no-op if the lengths are already < the desired values.
|
||||
//
|
||||
// The returned int indicates the amount of entries that were removed or unprepared.
|
||||
Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int
|
||||
|
||||
// Remove removes an item with the given ID.
|
||||
//
|
||||
// If a item has multiple entries in a timeline, they will all be removed.
|
||||
//
|
||||
// The returned int indicates the amount of entries that were removed.
|
||||
Remove(ctx context.Context, itemID string) (int, error)
|
||||
|
||||
// RemoveAllByOrBoosting removes all items created by or boosting the given accountID.
|
||||
//
|
||||
// The returned int indicates the amount of entries that were removed.
|
||||
RemoveAllByOrBoosting(ctx context.Context, accountID string) (int, error)
|
||||
}
|
||||
|
||||
// timeline fulfils the Timeline interface
|
||||
type timeline struct {
|
||||
items *indexedItems
|
||||
grabFunction GrabFunction
|
||||
filterFunction FilterFunction
|
||||
prepareFunction PrepareFunction
|
||||
timelineID string
|
||||
lastGot time.Time
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func (t *timeline) TimelineID() string {
|
||||
return t.timelineID
|
||||
}
|
||||
|
||||
// NewTimeline returns a new Timeline with
|
||||
// the given ID, using the given functions.
|
||||
func NewTimeline(
|
||||
ctx context.Context,
|
||||
timelineID string,
|
||||
grabFunction GrabFunction,
|
||||
filterFunction FilterFunction,
|
||||
prepareFunction PrepareFunction,
|
||||
skipInsertFunction SkipInsertFunction,
|
||||
) Timeline {
|
||||
return &timeline{
|
||||
items: &indexedItems{
|
||||
skipInsert: skipInsertFunction,
|
||||
},
|
||||
grabFunction: grabFunction,
|
||||
filterFunction: filterFunction,
|
||||
prepareFunction: prepareFunction,
|
||||
timelineID: timelineID,
|
||||
lastGot: time.Time{},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type TimelineStandardTestSuite struct {
|
||||
suite.Suite
|
||||
state *state.State
|
||||
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
highestStatusID string
|
||||
lowestStatusID string
|
||||
}
|
||||
|
||||
func (suite *TimelineStandardTestSuite) SetupSuite() {
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
}
|
||||
|
||||
func (suite *TimelineStandardTestSuite) SetupTest() {
|
||||
suite.state = new(state.State)
|
||||
|
||||
suite.state.Caches.Init()
|
||||
testrig.StartNoopWorkers(suite.state)
|
||||
|
||||
testrig.InitTestConfig()
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.state.DB = testrig.NewTestDB(suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
suite.state,
|
||||
visibility.NewFilter(suite.state),
|
||||
typeutils.NewConverter(suite.state),
|
||||
)
|
||||
|
||||
testrig.StandardDBSetup(suite.state.DB, nil)
|
||||
}
|
||||
|
||||
func (suite *TimelineStandardTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.state.DB)
|
||||
testrig.StopWorkers(suite.state)
|
||||
}
|
||||
|
||||
func (suite *TimelineStandardTestSuite) fillTimeline(timelineID string) {
|
||||
// Put testrig statuses in a determinate order
|
||||
// since we can't trust a map to keep order.
|
||||
statuses := []*gtsmodel.Status{}
|
||||
for _, s := range suite.testStatuses {
|
||||
statuses = append(statuses, s)
|
||||
}
|
||||
|
||||
sort.Slice(statuses, func(i, j int) bool {
|
||||
return statuses[i].ID > statuses[j].ID
|
||||
})
|
||||
|
||||
// Statuses are now highest -> lowest.
|
||||
suite.highestStatusID = statuses[0].ID
|
||||
suite.lowestStatusID = statuses[len(statuses)-1].ID
|
||||
if suite.highestStatusID < suite.lowestStatusID {
|
||||
suite.FailNow("", "statuses weren't ordered properly by sort")
|
||||
}
|
||||
|
||||
// Put all test statuses into the timeline; we don't
|
||||
// need to be fussy about who sees what for these tests.
|
||||
for _, status := range statuses {
|
||||
if _, err := suite.state.Timelines.Home.IngestOne(context.Background(), timelineID, status); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// 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
|
||||
|
||||
type Timelines struct {
|
||||
// Home provides access to account home timelines.
|
||||
Home Manager
|
||||
|
||||
// List provides access to list timelines.
|
||||
List Manager
|
||||
|
||||
// prevent pass-by-value.
|
||||
_ nocopy
|
||||
}
|
||||
|
||||
// nocopy when embedded will signal linter to
|
||||
// error on pass-by-value of parent struct.
|
||||
type nocopy struct{}
|
||||
|
||||
func (*nocopy) Lock() {}
|
||||
|
||||
func (*nocopy) Unlock() {}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
// 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
|
||||
|
||||
// Timelineable represents any item that can be indexed in a timeline.
|
||||
type Timelineable interface {
|
||||
GetID() string
|
||||
GetAccountID() string
|
||||
GetBoostOfID() string
|
||||
GetBoostOfAccountID() string
|
||||
}
|
||||
|
||||
// Preparable represents any item that can be prepared in a timeline.
|
||||
type Preparable interface {
|
||||
GetID() string
|
||||
GetAccountID() string
|
||||
GetBoostOfID() string
|
||||
GetBoostOfAccountID() string
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
// 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
)
|
||||
|
||||
type UnprepareTestSuite struct {
|
||||
TimelineStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *UnprepareTestSuite) TestUnprepareFromFave() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
testAccount = suite.testAccounts["local_account_1"]
|
||||
maxID = ""
|
||||
sinceID = ""
|
||||
minID = ""
|
||||
limit = 1
|
||||
local = false
|
||||
)
|
||||
|
||||
suite.fillTimeline(testAccount.ID)
|
||||
|
||||
// Get first status from the top (no params).
|
||||
statuses, err := suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
if len(statuses) != 1 {
|
||||
suite.FailNow("couldn't get top status")
|
||||
}
|
||||
|
||||
targetStatus := statuses[0].(*apimodel.Status)
|
||||
|
||||
// Check fave stats of the top status.
|
||||
suite.Equal(0, targetStatus.FavouritesCount)
|
||||
suite.False(targetStatus.Favourited)
|
||||
|
||||
// Fave the top status from testAccount.
|
||||
if err := suite.state.DB.PutStatusFave(ctx, >smodel.StatusFave{
|
||||
ID: id.NewULID(),
|
||||
AccountID: testAccount.ID,
|
||||
TargetAccountID: targetStatus.Account.ID,
|
||||
StatusID: targetStatus.ID,
|
||||
URI: "https://example.org/some/activity/path",
|
||||
}); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Repeat call to get first status from the top.
|
||||
// Get first status from the top (no params).
|
||||
statuses, err = suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
if len(statuses) != 1 {
|
||||
suite.FailNow("couldn't get top status")
|
||||
}
|
||||
|
||||
targetStatus = statuses[0].(*apimodel.Status)
|
||||
|
||||
// We haven't yet uncached/unprepared the status,
|
||||
// we've only inserted the fave, so counts should
|
||||
// stay the same...
|
||||
suite.Equal(0, targetStatus.FavouritesCount)
|
||||
suite.False(targetStatus.Favourited)
|
||||
|
||||
// Now call unprepare.
|
||||
suite.state.Timelines.Home.UnprepareItemFromAllTimelines(ctx, targetStatus.ID)
|
||||
|
||||
// Now a Get should trigger a fresh prepare of the
|
||||
// target status, and the counts should be updated.
|
||||
// Repeat call to get first status from the top.
|
||||
// Get first status from the top (no params).
|
||||
statuses, err = suite.state.Timelines.Home.GetTimeline(
|
||||
ctx,
|
||||
testAccount.ID,
|
||||
maxID,
|
||||
sinceID,
|
||||
minID,
|
||||
limit,
|
||||
local,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
if len(statuses) != 1 {
|
||||
suite.FailNow("couldn't get top status")
|
||||
}
|
||||
|
||||
targetStatus = statuses[0].(*apimodel.Status)
|
||||
|
||||
suite.Equal(1, targetStatus.FavouritesCount)
|
||||
suite.True(targetStatus.Favourited)
|
||||
}
|
||||
|
||||
func TestUnprepareTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(UnprepareTestSuite))
|
||||
}
|
||||
|
|
@ -25,14 +25,12 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/transport"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
|
|
@ -77,12 +75,6 @@ func (suite *TransportTestSuite) SetupTest() {
|
|||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
typeutils.NewConverter(&suite.state),
|
||||
)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
|
|
|
|||
|
|
@ -509,7 +509,9 @@ func (c *Converter) ASFollowToFollow(ctx context.Context, followable ap.Followab
|
|||
|
||||
follow := >smodel.Follow{
|
||||
URI: uri,
|
||||
Account: origin,
|
||||
AccountID: origin.ID,
|
||||
TargetAccount: target,
|
||||
TargetAccountID: target.ID,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/admin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
|
|
@ -529,12 +528,6 @@ func (suite *TypeUtilsTestSuite) TearDownTest() {
|
|||
// GetProcessor is a utility function that instantiates a processor.
|
||||
// Useful when a test in the test suite needs to change some state.
|
||||
func (suite *TypeUtilsTestSuite) GetProcessor() *processing.Processor {
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.typeconverter,
|
||||
)
|
||||
|
||||
httpClient := testrig.NewMockHTTPClient(nil, "../../testrig/media")
|
||||
transportController := testrig.NewTestTransportController(&suite.state, httpClient)
|
||||
mediaManager := testrig.NewTestMediaManager(&suite.state)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ import (
|
|||
"slices"
|
||||
)
|
||||
|
||||
// ToAny converts a slice of any input type
|
||||
// to the abstrace empty interface slice type.
|
||||
func ToAny[T any](in []T) []any {
|
||||
out := make([]any, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GrowJust increases slice capacity to guarantee
|
||||
// extra room 'size', where in the case that it does
|
||||
// need to allocate more it ONLY allocates 'size' extra.
|
||||
|
|
|
|||
|
|
@ -90,12 +90,6 @@ func (suite *RealSenderStandardTestSuite) SetupTest() {
|
|||
suite.state.Storage = suite.storage
|
||||
suite.typeconverter = typeutils.NewConverter(&suite.state)
|
||||
|
||||
testrig.StartTimelines(
|
||||
&suite.state,
|
||||
visibility.NewFilter(&suite.state),
|
||||
suite.typeconverter,
|
||||
)
|
||||
|
||||
suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media")
|
||||
suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople()
|
||||
suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue