Browse Source
* start messing about with timeline manager * i have no idea what i'm doing * i continue to not know what i'm doing * it's coming along * bit more progress * update timeline with new posts as they come in * lint and fmt * Select accounts where empty string * restructure a bunch, get unfaves working * moving stuff around * federate status deletes properly * mention regex better but not 100% there * fix regex * some more hacking away at the timeline code phew * fix up some little things * i can't even * more timeline stuff * move to ulid * fiddley * some lil fixes for kibou compatibility * timelines working pretty alright! * tidy + lintpull/48/head
96 changed files with 3453 additions and 1674 deletions
@ -1,8 +1,12 @@
|
||||
package emoji |
||||
|
||||
import "github.com/gin-gonic/gin" |
||||
import ( |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
// EmojisGETHandler returns a list of custom emojis enabled on the instance
|
||||
func (m *Module) EmojisGETHandler(c *gin.Context) { |
||||
|
||||
c.JSON(http.StatusOK, []string{}) |
||||
} |
||||
|
||||
@ -1,8 +1,12 @@
|
||||
package filter |
||||
|
||||
import "github.com/gin-gonic/gin" |
||||
import ( |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
// FiltersGETHandler returns a list of filters set by/for the authed account
|
||||
func (m *Module) FiltersGETHandler(c *gin.Context) { |
||||
|
||||
c.JSON(http.StatusOK, []string{}) |
||||
} |
||||
|
||||
@ -1,8 +1,12 @@
|
||||
package list |
||||
|
||||
import "github.com/gin-gonic/gin" |
||||
import ( |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
// ListsGETHandler returns a list of lists created by/for the authed account
|
||||
func (m *Module) ListsGETHandler(c *gin.Context) { |
||||
|
||||
c.JSON(http.StatusOK, []string{}) |
||||
} |
||||
|
||||
@ -0,0 +1,8 @@
|
||||
package model |
||||
|
||||
// StatusTimelineResponse wraps a slice of statuses, ready to be serialized, along with the Link
|
||||
// header for the previous and next queries, to be returned to the client.
|
||||
type StatusTimelineResponse struct { |
||||
Statuses []*Status |
||||
LinkHeader string |
||||
} |
||||
@ -0,0 +1,17 @@
|
||||
package security |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
const robotsString = `User-agent: * |
||||
Disallow: / |
||||
` |
||||
|
||||
// RobotsGETHandler returns the most restrictive possible robots.txt file in response to a call to /robots.txt.
|
||||
// The response instructs bots with *any* user agent not to index the instance at all.
|
||||
func (m *Module) RobotsGETHandler(c *gin.Context) { |
||||
c.String(http.StatusOK, robotsString) |
||||
} |
||||
@ -1,5 +0,0 @@
|
||||
# gtsmodel |
||||
|
||||
This package contains types used *internally* by GoToSocial and added/removed/selected from the database. As such, they contain sensitive fields which should **never** be serialized or reach the API level. Use the [mastotypes](../../pkg/mastotypes) package for that. |
||||
|
||||
The annotation used on these structs is for handling them via the go-pg ORM. See [here](https://pg.uptrace.dev/models/). |
||||
@ -0,0 +1,51 @@
|
||||
package id |
||||
|
||||
import ( |
||||
"crypto/rand" |
||||
"math/big" |
||||
"time" |
||||
|
||||
"github.com/oklog/ulid" |
||||
) |
||||
|
||||
const randomRange = 631152381 // ~20 years in seconds
|
||||
|
||||
// NewULID returns a new ULID string using the current time, or an error if something goes wrong.
|
||||
func NewULID() (string, error) { |
||||
newUlid, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
return newUlid.String(), nil |
||||
} |
||||
|
||||
// NewULIDFromTime returns a new ULID string using the given time, or an error if something goes wrong.
|
||||
func NewULIDFromTime(t time.Time) (string, error) { |
||||
newUlid, err := ulid.New(ulid.Timestamp(t), rand.Reader) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
return newUlid.String(), nil |
||||
} |
||||
|
||||
// NewRandomULID returns a new ULID string using a random time in an ~80 year range around the current datetime, or an error if something goes wrong.
|
||||
func NewRandomULID() (string, error) { |
||||
b1, err := rand.Int(rand.Reader, big.NewInt(randomRange)) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
r1 := time.Duration(int(b1.Int64())) |
||||
|
||||
b2, err := rand.Int(rand.Reader, big.NewInt(randomRange)) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
r2 := -time.Duration(int(b2.Int64())) |
||||
|
||||
arbitraryTime := time.Now().Add(r1 * time.Second).Add(r2 * time.Second) |
||||
newUlid, err := ulid.New(ulid.Timestamp(arbitraryTime), rand.Reader) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
return newUlid.String(), nil |
||||
} |
||||
@ -0,0 +1,79 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusBoost") |
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) |
||||
} |
||||
|
||||
if targetStatus.VisibilityAdvanced != nil { |
||||
if !targetStatus.VisibilityAdvanced.Boostable { |
||||
return nil, gtserror.NewErrorForbidden(errors.New("status is not boostable")) |
||||
} |
||||
} |
||||
|
||||
// it's visible! it's boostable! so let's boost the FUCK out of it
|
||||
boostWrapperStatus, err := p.tc.StatusToBoost(targetStatus, account) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
boostWrapperStatus.CreatedWithApplicationID = application.ID |
||||
boostWrapperStatus.GTSBoostedAccount = targetAccount |
||||
|
||||
// put the boost in the database
|
||||
if err := p.db.Put(boostWrapperStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// send it back to the processor for async processing
|
||||
p.fromClientAPI <- gtsmodel.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsAnnounce, |
||||
APActivityType: gtsmodel.ActivityStreamsCreate, |
||||
GTSModel: boostWrapperStatus, |
||||
OriginAccount: account, |
||||
TargetAccount: targetAccount, |
||||
} |
||||
|
||||
// return the frontend representation of the new status to the submitter
|
||||
mastoStatus, err := p.tc.StatusToMasto(boostWrapperStatus, account, account, targetAccount, nil, targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
} |
||||
@ -0,0 +1,74 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusBoostedBy") |
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("StatusBoostedBy: status is not visible")) |
||||
} |
||||
|
||||
// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
|
||||
favingAccounts, err := p.db.WhoBoostedStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error seeing who boosted status: %s", err)) |
||||
} |
||||
|
||||
// filter the list so the user doesn't see accounts they blocked or which blocked them
|
||||
filteredAccounts := []*gtsmodel.Account{} |
||||
for _, acc := range favingAccounts { |
||||
blocked, err := p.db.Blocked(account.ID, acc.ID) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusBoostedBy: error checking blocks: %s", err)) |
||||
} |
||||
if !blocked { |
||||
filteredAccounts = append(filteredAccounts, acc) |
||||
} |
||||
} |
||||
|
||||
// TODO: filter other things here? suspended? muted? silenced?
|
||||
|
||||
// now we can return the masto representation of those accounts
|
||||
mastoAccounts := []*apimodel.Account{} |
||||
for _, acc := range filteredAccounts { |
||||
mastoAccount, err := p.tc.AccountToMastoPublic(acc) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("StatusFavedBy: error converting account to api model: %s", err)) |
||||
} |
||||
mastoAccounts = append(mastoAccounts, mastoAccount) |
||||
} |
||||
|
||||
return mastoAccounts, nil |
||||
} |
||||
@ -0,0 +1,14 @@
|
||||
package status |
||||
|
||||
import ( |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) Context(account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) { |
||||
return &apimodel.Context{ |
||||
Ancestors: []apimodel.Status{}, |
||||
Descendants: []apimodel.Status{}, |
||||
}, nil |
||||
} |
||||
@ -0,0 +1,105 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"fmt" |
||||
"time" |
||||
|
||||
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/id" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
func (p *processor) Create(account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { |
||||
uris := util.GenerateURIsForAccount(account.Username, p.config.Protocol, p.config.Host) |
||||
thisStatusID, err := id.NewULID() |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
thisStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, thisStatusID) |
||||
thisStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, thisStatusID) |
||||
|
||||
newStatus := >smodel.Status{ |
||||
ID: thisStatusID, |
||||
URI: thisStatusURI, |
||||
URL: thisStatusURL, |
||||
CreatedAt: time.Now(), |
||||
UpdatedAt: time.Now(), |
||||
Local: true, |
||||
AccountID: account.ID, |
||||
ContentWarning: form.SpoilerText, |
||||
ActivityStreamsType: gtsmodel.ActivityStreamsNote, |
||||
Sensitive: form.Sensitive, |
||||
Language: form.Language, |
||||
CreatedWithApplicationID: application.ID, |
||||
Text: form.Status, |
||||
} |
||||
|
||||
// check if replyToID is ok
|
||||
if err := p.processReplyToID(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// check if mediaIDs are ok
|
||||
if err := p.processMediaIDs(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// check if visibility settings are ok
|
||||
if err := p.processVisibility(form, account.Privacy, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// handle language settings
|
||||
if err := p.processLanguage(form, account.Language, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// handle mentions
|
||||
if err := p.processMentions(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
if err := p.processTags(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
if err := p.processEmojis(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
if err := p.processContent(form, account.ID, newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// put the new status in the database, generating an ID for it in the process
|
||||
if err := p.db.Put(newStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// change the status ID of the media attachments to the new status
|
||||
for _, a := range newStatus.GTSMediaAttachments { |
||||
a.StatusID = newStatus.ID |
||||
a.UpdatedAt = time.Now() |
||||
if err := p.db.UpdateByID(a.ID, a); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
} |
||||
|
||||
// send it back to the processor for async processing
|
||||
p.fromClientAPI <- gtsmodel.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsNote, |
||||
APActivityType: gtsmodel.ActivityStreamsCreate, |
||||
GTSModel: newStatus, |
||||
OriginAccount: account, |
||||
} |
||||
|
||||
// return the frontend representation of the new status to the submitter
|
||||
mastoStatus, err := p.tc.StatusToMasto(newStatus, account, account, nil, newStatus.GTSReplyToAccount, nil) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", newStatus.ID, err)) |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
} |
||||
@ -0,0 +1,61 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusDelete") |
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
// status is already gone
|
||||
return nil, nil |
||||
} |
||||
|
||||
if targetStatus.AccountID != account.ID { |
||||
return nil, gtserror.NewErrorForbidden(errors.New("status doesn't belong to requesting account")) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := p.tc.StatusToMasto(targetStatus, account, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if err := p.db.DeleteByID(targetStatus.ID, >smodel.Status{}); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error deleting status from the database: %s", err)) |
||||
} |
||||
|
||||
// send it back to the processor for async processing
|
||||
p.fromClientAPI <- gtsmodel.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsNote, |
||||
APActivityType: gtsmodel.ActivityStreamsDelete, |
||||
GTSModel: targetStatus, |
||||
OriginAccount: account, |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
} |
||||
@ -0,0 +1,107 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/id" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
func (p *processor) Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusFave") |
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) |
||||
} |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) |
||||
} |
||||
|
||||
// is the status faveable?
|
||||
if targetStatus.VisibilityAdvanced != nil { |
||||
if !targetStatus.VisibilityAdvanced.Likeable { |
||||
return nil, gtserror.NewErrorForbidden(errors.New("status is not faveable")) |
||||
} |
||||
} |
||||
|
||||
// first check if the status is already faved, if so we don't need to do anything
|
||||
newFave := true |
||||
gtsFave := >smodel.StatusFave{} |
||||
if err := p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave); err == nil { |
||||
// we already have a fave for this status
|
||||
newFave = false |
||||
} |
||||
|
||||
if newFave { |
||||
thisFaveID, err := id.NewRandomULID() |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(err) |
||||
} |
||||
|
||||
// we need to create a new fave in the database
|
||||
gtsFave := >smodel.StatusFave{ |
||||
ID: thisFaveID, |
||||
AccountID: account.ID, |
||||
TargetAccountID: targetAccount.ID, |
||||
StatusID: targetStatus.ID, |
||||
URI: util.GenerateURIForLike(account.Username, p.config.Protocol, p.config.Host, thisFaveID), |
||||
GTSStatus: targetStatus, |
||||
GTSTargetAccount: targetAccount, |
||||
GTSFavingAccount: account, |
||||
} |
||||
|
||||
if err := p.db.Put(gtsFave); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting fave in database: %s", err)) |
||||
} |
||||
|
||||
// send it back to the processor for async processing
|
||||
p.fromClientAPI <- gtsmodel.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsLike, |
||||
APActivityType: gtsmodel.ActivityStreamsCreate, |
||||
GTSModel: gtsFave, |
||||
OriginAccount: account, |
||||
TargetAccount: targetAccount, |
||||
} |
||||
} |
||||
|
||||
// return the mastodon representation of the target status
|
||||
mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
} |
||||
@ -0,0 +1,74 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) FavedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusFavedBy") |
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) |
||||
} |
||||
|
||||
// get ALL accounts that faved a status -- doesn't take account of blocks and mutes and stuff
|
||||
favingAccounts, err := p.db.WhoFavedStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing who faved status: %s", err)) |
||||
} |
||||
|
||||
// filter the list so the user doesn't see accounts they blocked or which blocked them
|
||||
filteredAccounts := []*gtsmodel.Account{} |
||||
for _, acc := range favingAccounts { |
||||
blocked, err := p.db.Blocked(account.ID, acc.ID) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking blocks: %s", err)) |
||||
} |
||||
if !blocked { |
||||
filteredAccounts = append(filteredAccounts, acc) |
||||
} |
||||
} |
||||
|
||||
// TODO: filter other things here? suspended? muted? silenced?
|
||||
|
||||
// now we can return the masto representation of those accounts
|
||||
mastoAccounts := []*apimodel.Account{} |
||||
for _, acc := range filteredAccounts { |
||||
mastoAccount, err := p.tc.AccountToMastoPublic(acc) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
mastoAccounts = append(mastoAccounts, mastoAccount) |
||||
} |
||||
|
||||
return mastoAccounts, nil |
||||
} |
||||
@ -0,0 +1,58 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) Get(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusGet") |
||||
|
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) |
||||
} |
||||
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
|
||||
} |
||||
@ -0,0 +1,52 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"github.com/sirupsen/logrus" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils" |
||||
) |
||||
|
||||
// Processor wraps a bunch of functions for processing statuses.
|
||||
type Processor interface { |
||||
// Create processes the given form to create a new status, returning the api model representation of that status if it's OK.
|
||||
Create(account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) |
||||
// Delete processes the delete of a given status, returning the deleted status if the delete goes through.
|
||||
Delete(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) |
||||
// Fave processes the faving of a given status, returning the updated status if the fave goes through.
|
||||
Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) |
||||
// Boost processes the boost/reblog of a given status, returning the newly-created boost if all is well.
|
||||
Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) |
||||
// BoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings.
|
||||
BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) |
||||
// FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.
|
||||
FavedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) |
||||
// Get gets the given status, taking account of privacy settings and blocks etc.
|
||||
Get(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) |
||||
// Unfave processes the unfaving of a given status, returning the updated status if the fave goes through.
|
||||
Unfave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) |
||||
// Context returns the context (previous and following posts) from the given status ID
|
||||
Context(account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) |
||||
} |
||||
|
||||
type processor struct { |
||||
tc typeutils.TypeConverter |
||||
config *config.Config |
||||
db db.DB |
||||
fromClientAPI chan gtsmodel.FromClientAPI |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
// New returns a new status processor.
|
||||
func New(db db.DB, tc typeutils.TypeConverter, config *config.Config, fromClientAPI chan gtsmodel.FromClientAPI, log *logrus.Logger) Processor { |
||||
return &processor{ |
||||
tc: tc, |
||||
config: config, |
||||
db: db, |
||||
fromClientAPI: fromClientAPI, |
||||
log: log, |
||||
} |
||||
} |
||||
@ -0,0 +1,92 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (p *processor) Unfave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { |
||||
l := p.log.WithField("func", "StatusUnfave") |
||||
l.Tracef("going to search for target status %s", targetStatusID) |
||||
targetStatus := >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Tracef("going to search for target account %s", targetStatus.AccountID) |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err)) |
||||
} |
||||
|
||||
l.Trace("going to get relevant accounts") |
||||
relevantAccounts, err := p.db.PullRelevantAccountsFromStatus(targetStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching related accounts for status %s: %s", targetStatusID, err)) |
||||
} |
||||
|
||||
l.Trace("going to see if status is visible") |
||||
visible, err := p.db.StatusVisible(targetStatus, account, relevantAccounts) // requestingAccount might well be nil here, but StatusVisible knows how to take care of that
|
||||
if err != nil { |
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
if !visible { |
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible")) |
||||
} |
||||
|
||||
// check if we actually have a fave for this status
|
||||
var toUnfave bool |
||||
|
||||
gtsFave := >smodel.StatusFave{} |
||||
err = p.db.GetWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave) |
||||
if err == nil { |
||||
// we have a fave
|
||||
toUnfave = true |
||||
} |
||||
if err != nil { |
||||
// something went wrong in the db finding the fave
|
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching existing fave from database: %s", err)) |
||||
} |
||||
// we just don't have a fave
|
||||
toUnfave = false |
||||
} |
||||
|
||||
if toUnfave { |
||||
// we had a fave, so take some action to get rid of it
|
||||
if err := p.db.DeleteWhere([]db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: account.ID}}, gtsFave); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err)) |
||||
} |
||||
|
||||
// send it back to the processor for async processing
|
||||
p.fromClientAPI <- gtsmodel.FromClientAPI{ |
||||
APObjectType: gtsmodel.ActivityStreamsLike, |
||||
APActivityType: gtsmodel.ActivityStreamsUndo, |
||||
GTSModel: gtsFave, |
||||
OriginAccount: account, |
||||
TargetAccount: targetAccount, |
||||
} |
||||
} |
||||
|
||||
// return the status (whatever its state) back to the caller
|
||||
var boostOfStatus *gtsmodel.Status |
||||
if targetStatus.BoostOfID != "" { |
||||
boostOfStatus = >smodel.Status{} |
||||
if err := p.db.GetByID(targetStatus.BoostOfID, boostOfStatus); err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching boosted status %s: %s", targetStatus.BoostOfID, err)) |
||||
} |
||||
} |
||||
|
||||
mastoStatus, err := p.tc.StatusToMasto(targetStatus, targetAccount, account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, boostOfStatus) |
||||
if err != nil { |
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err)) |
||||
} |
||||
|
||||
return mastoStatus, nil |
||||
} |
||||
@ -0,0 +1,269 @@
|
||||
package status |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/id" |
||||
"github.com/superseriousbusiness/gotosocial/internal/util" |
||||
) |
||||
|
||||
func (p *processor) processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { |
||||
// by default all flags are set to true
|
||||
gtsAdvancedVis := >smodel.VisibilityAdvanced{ |
||||
Federated: true, |
||||
Boostable: true, |
||||
Replyable: true, |
||||
Likeable: true, |
||||
} |
||||
|
||||
var gtsBasicVis gtsmodel.Visibility |
||||
// Advanced takes priority if it's set.
|
||||
// If it's not set, take whatever masto visibility is set.
|
||||
// If *that's* not set either, then just take the account default.
|
||||
// If that's also not set, take the default for the whole instance.
|
||||
if form.VisibilityAdvanced != nil { |
||||
gtsBasicVis = gtsmodel.Visibility(*form.VisibilityAdvanced) |
||||
} else if form.Visibility != "" { |
||||
gtsBasicVis = p.tc.MastoVisToVis(form.Visibility) |
||||
} else if accountDefaultVis != "" { |
||||
gtsBasicVis = accountDefaultVis |
||||
} else { |
||||
gtsBasicVis = gtsmodel.VisibilityDefault |
||||
} |
||||
|
||||
switch gtsBasicVis { |
||||
case gtsmodel.VisibilityPublic: |
||||
// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
|
||||
break |
||||
case gtsmodel.VisibilityUnlocked: |
||||
// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
|
||||
if form.Federated != nil { |
||||
gtsAdvancedVis.Federated = *form.Federated |
||||
} |
||||
|
||||
if form.Boostable != nil { |
||||
gtsAdvancedVis.Boostable = *form.Boostable |
||||
} |
||||
|
||||
if form.Replyable != nil { |
||||
gtsAdvancedVis.Replyable = *form.Replyable |
||||
} |
||||
|
||||
if form.Likeable != nil { |
||||
gtsAdvancedVis.Likeable = *form.Likeable |
||||
} |
||||
|
||||
case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: |
||||
// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
|
||||
gtsAdvancedVis.Boostable = false |
||||
|
||||
if form.Federated != nil { |
||||
gtsAdvancedVis.Federated = *form.Federated |
||||
} |
||||
|
||||
if form.Replyable != nil { |
||||
gtsAdvancedVis.Replyable = *form.Replyable |
||||
} |
||||
|
||||
if form.Likeable != nil { |
||||
gtsAdvancedVis.Likeable = *form.Likeable |
||||
} |
||||
|
||||
case gtsmodel.VisibilityDirect: |
||||
// direct is pretty easy: there's only one possible setting so return it
|
||||
gtsAdvancedVis.Federated = true |
||||
gtsAdvancedVis.Boostable = false |
||||
gtsAdvancedVis.Federated = true |
||||
gtsAdvancedVis.Likeable = true |
||||
} |
||||
|
||||
status.Visibility = gtsBasicVis |
||||
status.VisibilityAdvanced = gtsAdvancedVis |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processReplyToID(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { |
||||
if form.InReplyToID == "" { |
||||
return nil |
||||
} |
||||
|
||||
// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
|
||||
//
|
||||
// 1. Does the replied status exist in the database?
|
||||
// 2. Is the replied status marked as replyable?
|
||||
// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
|
||||
//
|
||||
// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
|
||||
repliedStatus := >smodel.Status{} |
||||
repliedAccount := >smodel.Account{} |
||||
// check replied status exists + is replyable
|
||||
if err := p.db.GetByID(form.InReplyToID, repliedStatus); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); ok { |
||||
return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) |
||||
} |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
|
||||
if repliedStatus.VisibilityAdvanced != nil { |
||||
if !repliedStatus.VisibilityAdvanced.Replyable { |
||||
return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) |
||||
} |
||||
} |
||||
|
||||
// check replied account is known to us
|
||||
if err := p.db.GetByID(repliedStatus.AccountID, repliedAccount); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); ok { |
||||
return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) |
||||
} |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
// check if a block exists
|
||||
if blocked, err := p.db.Blocked(thisAccountID, repliedAccount.ID); err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) |
||||
} |
||||
} else if blocked { |
||||
return fmt.Errorf("status with id %s not replyable", form.InReplyToID) |
||||
} |
||||
status.InReplyToID = repliedStatus.ID |
||||
status.InReplyToAccountID = repliedAccount.ID |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processMediaIDs(form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { |
||||
if form.MediaIDs == nil { |
||||
return nil |
||||
} |
||||
|
||||
gtsMediaAttachments := []*gtsmodel.MediaAttachment{} |
||||
attachments := []string{} |
||||
for _, mediaID := range form.MediaIDs { |
||||
// check these attachments exist
|
||||
a := >smodel.MediaAttachment{} |
||||
if err := p.db.GetByID(mediaID, a); err != nil { |
||||
return fmt.Errorf("invalid media type or media not found for media id %s", mediaID) |
||||
} |
||||
// check they belong to the requesting account id
|
||||
if a.AccountID != thisAccountID { |
||||
return fmt.Errorf("media with id %s does not belong to account %s", mediaID, thisAccountID) |
||||
} |
||||
// check they're not already used in a status
|
||||
if a.StatusID != "" || a.ScheduledStatusID != "" { |
||||
return fmt.Errorf("media with id %s is already attached to a status", mediaID) |
||||
} |
||||
gtsMediaAttachments = append(gtsMediaAttachments, a) |
||||
attachments = append(attachments, a.ID) |
||||
} |
||||
status.GTSMediaAttachments = gtsMediaAttachments |
||||
status.Attachments = attachments |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { |
||||
if form.Language != "" { |
||||
status.Language = form.Language |
||||
} else { |
||||
status.Language = accountDefaultLanguage |
||||
} |
||||
if status.Language == "" { |
||||
return errors.New("no language given either in status create form or account default") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processMentions(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
menchies := []string{} |
||||
gtsMenchies, err := p.db.MentionStringsToMentions(util.DeriveMentionsFromStatus(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating mentions from status: %s", err) |
||||
} |
||||
for _, menchie := range gtsMenchies { |
||||
menchieID, err := id.NewRandomULID() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
menchie.ID = menchieID |
||||
|
||||
if err := p.db.Put(menchie); err != nil { |
||||
return fmt.Errorf("error putting mentions in db: %s", err) |
||||
} |
||||
menchies = append(menchies, menchie.ID) |
||||
} |
||||
// add full populated gts menchies to the status for passing them around conveniently
|
||||
status.GTSMentions = gtsMenchies |
||||
// add just the ids of the mentioned accounts to the status for putting in the db
|
||||
status.Mentions = menchies |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processTags(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
tags := []string{} |
||||
gtsTags, err := p.db.TagStringsToTags(util.DeriveHashtagsFromStatus(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating hashtags from status: %s", err) |
||||
} |
||||
for _, tag := range gtsTags { |
||||
if err := p.db.Upsert(tag, "name"); err != nil { |
||||
return fmt.Errorf("error putting tags in db: %s", err) |
||||
} |
||||
tags = append(tags, tag.ID) |
||||
} |
||||
// add full populated gts tags to the status for passing them around conveniently
|
||||
status.GTSTags = gtsTags |
||||
// add just the ids of the used tags to the status for putting in the db
|
||||
status.Tags = tags |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processEmojis(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
emojis := []string{} |
||||
gtsEmojis, err := p.db.EmojiStringsToEmojis(util.DeriveEmojisFromStatus(form.Status), accountID, status.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("error generating emojis from status: %s", err) |
||||
} |
||||
for _, e := range gtsEmojis { |
||||
emojis = append(emojis, e.ID) |
||||
} |
||||
// add full populated gts emojis to the status for passing them around conveniently
|
||||
status.GTSEmojis = gtsEmojis |
||||
// add just the ids of the used emojis to the status for putting in the db
|
||||
status.Emojis = emojis |
||||
return nil |
||||
} |
||||
|
||||
func (p *processor) processContent(form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { |
||||
if form.Status == "" { |
||||
status.Content = "" |
||||
return nil |
||||
} |
||||
|
||||
// surround the whole status in '<p>'
|
||||
content := fmt.Sprintf(`<p>%s</p>`, form.Status) |
||||
|
||||
// format mentions nicely
|
||||
for _, menchie := range status.GTSMentions { |
||||
targetAccount := >smodel.Account{} |
||||
if err := p.db.GetByID(menchie.TargetAccountID, targetAccount); err == nil { |
||||
mentionContent := fmt.Sprintf(`<span class="h-card"><a href="%s" class="u-url mention">@<span>%s</span></a></span>`, targetAccount.URL, targetAccount.Username) |
||||
content = strings.ReplaceAll(content, menchie.NameString, mentionContent) |
||||
} |
||||
} |
||||
|
||||
// format tags nicely
|
||||
for _, tag := range status.GTSTags { |
||||
tagContent := fmt.Sprintf(`<a href="%s" class="mention hashtag" rel="tag">#<span>%s</span></a>`, tag.URL, tag.Name) |
||||
content = strings.ReplaceAll(content, fmt.Sprintf("#%s", tag.Name), tagContent) |
||||
} |
||||
|
||||
// replace newlines with breaks
|
||||
content = strings.ReplaceAll(content, "\n", "<br />") |
||||
|
||||
status.Content = content |
||||
return nil |
||||
} |
||||
@ -0,0 +1,309 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"container/list" |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
) |
||||
|
||||
func (t *timeline) Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) { |
||||
l := t.log.WithFields(logrus.Fields{ |
||||
"func": "Get", |
||||
"accountID": t.accountID, |
||||
}) |
||||
|
||||
var statuses []*apimodel.Status |
||||
var err error |
||||
|
||||
// no params are defined to just fetch from the top
|
||||
if maxID == "" && sinceID == "" && minID == "" { |
||||
statuses, err = t.GetXFromTop(amount) |
||||
// aysnchronously prepare the next predicted query so it's ready when the user asks for it
|
||||
if len(statuses) != 0 { |
||||
nextMaxID := statuses[len(statuses)-1].ID |
||||
go func() { |
||||
if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { |
||||
l.Errorf("error preparing next query: %s", err) |
||||
} |
||||
}() |
||||
} |
||||
} |
||||
|
||||
// maxID is defined but sinceID isn't so take from behind
|
||||
if maxID != "" && sinceID == "" { |
||||
statuses, err = t.GetXBehindID(amount, maxID) |
||||
// aysnchronously prepare the next predicted query so it's ready when the user asks for it
|
||||
if len(statuses) != 0 { |
||||
nextMaxID := statuses[len(statuses)-1].ID |
||||
go func() { |
||||
if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { |
||||
l.Errorf("error preparing next query: %s", err) |
||||
} |
||||
}() |
||||
} |
||||
} |
||||
|
||||
// maxID is defined and sinceID || minID are as well, so take a slice between them
|
||||
if maxID != "" && sinceID != "" { |
||||
statuses, err = t.GetXBetweenID(amount, maxID, minID) |
||||
} |
||||
if maxID != "" && minID != "" { |
||||
statuses, err = t.GetXBetweenID(amount, maxID, minID) |
||||
} |
||||
|
||||
// maxID isn't defined, but sinceID || minID are, so take x before
|
||||
if maxID == "" && sinceID != "" { |
||||
statuses, err = t.GetXBeforeID(amount, sinceID, true) |
||||
} |
||||
if maxID == "" && minID != "" { |
||||
statuses, err = t.GetXBeforeID(amount, minID, true) |
||||
} |
||||
|
||||
return statuses, err |
||||
} |
||||
|
||||
func (t *timeline) GetXFromTop(amount int) ([]*apimodel.Status, error) { |
||||
// make a slice of statuses with the length we need to return
|
||||
statuses := make([]*apimodel.Status, 0, amount) |
||||
|
||||
if t.preparedPosts.data == nil { |
||||
t.preparedPosts.data = &list.List{} |
||||
} |
||||
|
||||
// make sure we have enough posts prepared to return
|
||||
if t.preparedPosts.data.Len() < amount { |
||||
if err := t.PrepareFromTop(amount); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
// work through the prepared posts from the top and return
|
||||
var served int |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXFromTop: could not parse e as a preparedPostsEntry") |
||||
} |
||||
statuses = append(statuses, entry.prepared) |
||||
served = served + 1 |
||||
if served >= amount { |
||||
break |
||||
} |
||||
} |
||||
|
||||
return statuses, nil |
||||
} |
||||
|
||||
func (t *timeline) GetXBehindID(amount int, behindID string) ([]*apimodel.Status, error) { |
||||
// make a slice of statuses with the length we need to return
|
||||
statuses := make([]*apimodel.Status, 0, amount) |
||||
|
||||
if t.preparedPosts.data == nil { |
||||
t.preparedPosts.data = &list.List{} |
||||
} |
||||
|
||||
// iterate through the modified list until we hit the mark we're looking for
|
||||
var position int |
||||
var behindIDMark *list.Element |
||||
|
||||
findMarkLoop: |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
position = position + 1 |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
if entry.statusID == behindID { |
||||
behindIDMark = e |
||||
break findMarkLoop |
||||
} |
||||
} |
||||
|
||||
// we didn't find it, so we need to make sure it's indexed and prepared and then try again
|
||||
if behindIDMark == nil { |
||||
if err := t.IndexBehind(behindID, amount); err != nil { |
||||
return nil, fmt.Errorf("GetXBehindID: error indexing behind and including ID %s", behindID) |
||||
} |
||||
if err := t.PrepareBehind(behindID, amount); err != nil { |
||||
return nil, fmt.Errorf("GetXBehindID: error preparing behind and including ID %s", behindID) |
||||
} |
||||
oldestID, err := t.OldestPreparedPostID() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if oldestID == "" || oldestID == behindID { |
||||
// there is no oldest prepared post, or the oldest prepared post is still the post we're looking for entries after
|
||||
// this means we should just return the empty statuses slice since we don't have any more posts to offer
|
||||
return statuses, nil |
||||
} |
||||
return t.GetXBehindID(amount, behindID) |
||||
} |
||||
|
||||
// make sure we have enough posts prepared behind it to return what we're being asked for
|
||||
if t.preparedPosts.data.Len() < amount+position { |
||||
if err := t.PrepareBehind(behindID, amount); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
// start serving from the entry right after the mark
|
||||
var served int |
||||
serveloop: |
||||
for e := behindIDMark.Next(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
// serve up to the amount requested
|
||||
statuses = append(statuses, entry.prepared) |
||||
served = served + 1 |
||||
if served >= amount { |
||||
break serveloop |
||||
} |
||||
} |
||||
|
||||
return statuses, nil |
||||
} |
||||
|
||||
func (t *timeline) GetXBeforeID(amount int, beforeID string, startFromTop bool) ([]*apimodel.Status, error) { |
||||
// make a slice of statuses with the length we need to return
|
||||
statuses := make([]*apimodel.Status, 0, amount) |
||||
|
||||
if t.preparedPosts.data == nil { |
||||
t.preparedPosts.data = &list.List{} |
||||
} |
||||
|
||||
// iterate through the modified list until we hit the mark we're looking for
|
||||
var beforeIDMark *list.Element |
||||
findMarkLoop: |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
if entry.statusID == beforeID { |
||||
beforeIDMark = e |
||||
break findMarkLoop |
||||
} |
||||
} |
||||
|
||||
// we didn't find it, so we need to make sure it's indexed and prepared and then try again
|
||||
if beforeIDMark == nil { |
||||
if err := t.IndexBefore(beforeID, true, amount); err != nil { |
||||
return nil, fmt.Errorf("GetXBeforeID: error indexing before and including ID %s", beforeID) |
||||
} |
||||
if err := t.PrepareBefore(beforeID, true, amount); err != nil { |
||||
return nil, fmt.Errorf("GetXBeforeID: error preparing before and including ID %s", beforeID) |
||||
} |
||||
return t.GetXBeforeID(amount, beforeID, startFromTop) |
||||
} |
||||
|
||||
var served int |
||||
|
||||
if startFromTop { |
||||
// start serving from the front/top and keep going until we hit mark or get x amount statuses
|
||||
serveloopFromTop: |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
if entry.statusID == beforeID { |
||||
break serveloopFromTop |
||||
} |
||||
|
||||
// serve up to the amount requested
|
||||
statuses = append(statuses, entry.prepared) |
||||
served = served + 1 |
||||
if served >= amount { |
||||
break serveloopFromTop |
||||
} |
||||
} |
||||
} else if !startFromTop { |
||||
// start serving from the entry right before the mark
|
||||
serveloopFromBottom: |
||||
for e := beforeIDMark.Prev(); e != nil; e = e.Prev() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
// serve up to the amount requested
|
||||
statuses = append(statuses, entry.prepared) |
||||
served = served + 1 |
||||
if served >= amount { |
||||
break serveloopFromBottom |
||||
} |
||||
} |
||||
} |
||||
|
||||
return statuses, nil |
||||
} |
||||
|
||||
func (t *timeline) GetXBetweenID(amount int, behindID string, beforeID string) ([]*apimodel.Status, error) { |
||||
// make a slice of statuses with the length we need to return
|
||||
statuses := make([]*apimodel.Status, 0, amount) |
||||
|
||||
if t.preparedPosts.data == nil { |
||||
t.preparedPosts.data = &list.List{} |
||||
} |
||||
|
||||
// iterate through the modified list until we hit the mark we're looking for
|
||||
var position int |
||||
var behindIDMark *list.Element |
||||
findMarkLoop: |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
position = position + 1 |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
if entry.statusID == behindID { |
||||
behindIDMark = e |
||||
break findMarkLoop |
||||
} |
||||
} |
||||
|
||||
// we didn't find it
|
||||
if behindIDMark == nil { |
||||
return nil, fmt.Errorf("GetXBetweenID: couldn't find status with ID %s", behindID) |
||||
} |
||||
|
||||
// make sure we have enough posts prepared behind it to return what we're being asked for
|
||||
if t.preparedPosts.data.Len() < amount+position { |
||||
if err := t.PrepareBehind(behindID, amount); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
// start serving from the entry right after the mark
|
||||
var served int |
||||
serveloop: |
||||
for e := behindIDMark.Next(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
if entry.statusID == beforeID { |
||||
break serveloop |
||||
} |
||||
|
||||
// serve up to the amount requested
|
||||
statuses = append(statuses, entry.prepared) |
||||
served = served + 1 |
||||
if served >= amount { |
||||
break serveloop |
||||
} |
||||
} |
||||
|
||||
return statuses, nil |
||||
} |
||||
@ -0,0 +1,143 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (t *timeline) IndexBefore(statusID string, include bool, amount int) error { |
||||
// filtered := []*gtsmodel.Status{}
|
||||
// offsetStatus := statusID
|
||||
|
||||
// grabloop:
|
||||
// for len(filtered) < amount {
|
||||
// statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, amount, offsetStatus, include, true)
|
||||
// if err != nil {
|
||||
// if _, ok := err.(db.ErrNoEntries); !ok {
|
||||
// return fmt.Errorf("IndexBeforeAndIncluding: error getting statuses from db: %s", err)
|
||||
// }
|
||||
// break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail
|
||||
// }
|
||||
|
||||
// for _, s := range statuses {
|
||||
// relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(s)
|
||||
// if err != nil {
|
||||
// continue
|
||||
// }
|
||||
// visible, err := t.db.StatusVisible(s, t.account, relevantAccounts)
|
||||
// if err != nil {
|
||||
// continue
|
||||
// }
|
||||
// if visible {
|
||||
// filtered = append(filtered, s)
|
||||
// }
|
||||
// offsetStatus = s.ID
|
||||
// }
|
||||
// }
|
||||
|
||||
// for _, s := range filtered {
|
||||
// if err := t.IndexOne(s.CreatedAt, s.ID); err != nil {
|
||||
// return fmt.Errorf("IndexBeforeAndIncluding: error indexing status with id %s: %s", s.ID, err)
|
||||
// }
|
||||
// }
|
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) IndexBehind(statusID string, amount int) error { |
||||
filtered := []*gtsmodel.Status{} |
||||
offsetStatus := statusID |
||||
|
||||
grabloop: |
||||
for len(filtered) < amount { |
||||
statuses, err := t.db.GetStatusesWhereFollowing(t.accountID, offsetStatus, "", "", amount, false) |
||||
if err != nil { |
||||
if _, ok := err.(db.ErrNoEntries); ok { |
||||
break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail
|
||||
} |
||||
return fmt.Errorf("IndexBehindAndIncluding: error getting statuses from db: %s", err) |
||||
} |
||||
|
||||
for _, s := range statuses { |
||||
relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(s) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
visible, err := t.db.StatusVisible(s, t.account, relevantAccounts) |
||||
if err != nil { |
||||
continue |
||||
} |
||||
if visible { |
||||
filtered = append(filtered, s) |
||||
} |
||||
offsetStatus = s.ID |
||||
} |
||||
} |
||||
|
||||
for _, s := range filtered { |
||||
if err := t.IndexOne(s.CreatedAt, s.ID); err != nil { |
||||
return fmt.Errorf("IndexBehindAndIncluding: error indexing status with id %s: %s", s.ID, err) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) IndexOneByID(statusID string) error { |
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) IndexOne(statusCreatedAt time.Time, statusID string) error { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
|
||||
postIndexEntry := &postIndexEntry{ |
||||
statusID: statusID, |
||||
} |
||||
|
||||
return t.postIndex.insertIndexed(postIndexEntry) |
||||
} |
||||
|
||||
func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
|
||||
postIndexEntry := &postIndexEntry{ |
||||
statusID: statusID, |
||||
} |
||||
|
||||
if err := t.postIndex.insertIndexed(postIndexEntry); err != nil { |
||||
return fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err) |
||||
} |
||||
|
||||
if err := t.prepare(statusID); err != nil { |
||||
return fmt.Errorf("IndexAndPrepareOne: error preparing: %s", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) OldestIndexedPostID() (string, error) { |
||||
var id string |
||||
if t.postIndex == nil || t.postIndex.data == nil { |
||||
// return an empty string if postindex hasn't been initialized yet
|
||||
return id, nil |
||||
} |
||||
|
||||
e := t.postIndex.data.Back() |
||||
|
||||
if e == nil { |
||||
// return an empty string if there's no back entry (ie., the index list hasn't been initialized yet)
|
||||
return id, nil |
||||
} |
||||
|
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry") |
||||
} |
||||
return entry.statusID, nil |
||||
} |
||||
@ -0,0 +1,217 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org |
||||
|
||||
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 ( |
||||
"fmt" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/config" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils" |
||||
) |
||||
|
||||
const ( |
||||
desiredPostIndexLength = 400 |
||||
) |
||||
|
||||
// Manager abstracts functions for creating timelines for multiple accounts, and adding, removing, and fetching entries from those timelines.
|
||||
//
|
||||
// By the time a status hits the manager interface, it should already have been filtered and it should be established that the status indeed
|
||||
// belongs in the home timeline of the given account ID.
|
||||
//
|
||||
// The manager makes a distinction between *indexed* posts and *prepared* posts.
|
||||
//
|
||||
// Indexed posts consist of just that post's ID (in the database) and the time it was created. An indexed post takes up very little memory, so
|
||||
// it's not a huge priority to keep trimming the indexed posts list.
|
||||
//
|
||||
// Prepared posts consist of the post's database ID, the time it was created, AND the apimodel representation of that post, for quick serialization.
|
||||
// Prepared posts of course take up more memory than indexed posts, so they should be regularly pruned if they're not being actively served.
|
||||
type Manager interface { |
||||
// Ingest takes one status and indexes it into the timeline for the given account ID.
|
||||
//
|
||||
// It should already be established before calling this function that the status/post actually belongs in the timeline!
|
||||
Ingest(status *gtsmodel.Status, timelineAccountID string) error |
||||
// IngestAndPrepare takes one status and indexes it into the timeline for the given account ID, and then immediately prepares it for serving.
|
||||
// This is useful in cases where we know the status 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 status/post actually belongs in the timeline!
|
||||
IngestAndPrepare(status *gtsmodel.Status, timelineAccountID string) error |
||||
// HomeTimeline returns limit n amount of entries from the home timeline of the given account ID, in descending chronological order.
|
||||
// If maxID is provided, it will return entries from that maxID onwards, inclusive.
|
||||
HomeTimeline(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) |
||||
// GetIndexedLength returns the amount of posts/statuses that have been *indexed* for the given account ID.
|
||||
GetIndexedLength(timelineAccountID string) int |
||||
// GetDesiredIndexLength returns the amount of posts that we, ideally, index for each user.
|
||||
GetDesiredIndexLength() int |
||||
// GetOldestIndexedID returns the status ID for the oldest post that we have indexed for the given account.
|
||||
GetOldestIndexedID(timelineAccountID string) (string, error) |
||||
// PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index.
|
||||
PrepareXFromTop(timelineAccountID string, limit int) error |
||||
// WipeStatusFromTimeline completely removes a status and from the index and prepared posts of the given account ID
|
||||
//
|
||||
// The returned int indicates how many entries were removed.
|
||||
WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) |
||||
// WipeStatusFromAllTimelines removes the status from the index and prepared posts of all timelines
|
||||
WipeStatusFromAllTimelines(statusID string) error |
||||
} |
||||
|
||||
// NewManager returns a new timeline manager with the given database, typeconverter, config, and log.
|
||||
func NewManager(db db.DB, tc typeutils.TypeConverter, config *config.Config, log *logrus.Logger) Manager { |
||||
return &manager{ |
||||
accountTimelines: sync.Map{}, |
||||
db: db, |
||||
tc: tc, |
||||
config: config, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
type manager struct { |
||||
accountTimelines sync.Map |
||||
db db.DB |
||||
tc typeutils.TypeConverter |
||||
config *config.Config |
||||
log *logrus.Logger |
||||
} |
||||
|
||||
func (m *manager) Ingest(status *gtsmodel.Status, timelineAccountID string) error { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "Ingest", |
||||
"timelineAccountID": timelineAccountID, |
||||
"statusID": status.ID, |
||||
}) |
||||
|
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
l.Trace("ingesting status") |
||||
return t.IndexOne(status.CreatedAt, status.ID) |
||||
} |
||||
|
||||
func (m *manager) IngestAndPrepare(status *gtsmodel.Status, timelineAccountID string) error { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "IngestAndPrepare", |
||||
"timelineAccountID": timelineAccountID, |
||||
"statusID": status.ID, |
||||
}) |
||||
|
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
l.Trace("ingesting status") |
||||
return t.IndexAndPrepareOne(status.CreatedAt, status.ID) |
||||
} |
||||
|
||||
func (m *manager) Remove(statusID string, timelineAccountID string) (int, error) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "Remove", |
||||
"timelineAccountID": timelineAccountID, |
||||
"statusID": statusID, |
||||
}) |
||||
|
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
l.Trace("removing status") |
||||
return t.Remove(statusID) |
||||
} |
||||
|
||||
func (m *manager) HomeTimeline(timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) { |
||||
l := m.log.WithFields(logrus.Fields{ |
||||
"func": "HomeTimelineGet", |
||||
"timelineAccountID": timelineAccountID, |
||||
}) |
||||
|
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
statuses, err := t.Get(limit, maxID, sinceID, minID) |
||||
if err != nil { |
||||
l.Errorf("error getting statuses: %s", err) |
||||
} |
||||
return statuses, nil |
||||
} |
||||
|
||||
func (m *manager) GetIndexedLength(timelineAccountID string) int { |
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
return t.PostIndexLength() |
||||
} |
||||
|
||||
func (m *manager) GetDesiredIndexLength() int { |
||||
return desiredPostIndexLength |
||||
} |
||||
|
||||
func (m *manager) GetOldestIndexedID(timelineAccountID string) (string, error) { |
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
return t.OldestIndexedPostID() |
||||
} |
||||
|
||||
func (m *manager) PrepareXFromTop(timelineAccountID string, limit int) error { |
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
return t.PrepareFromTop(limit) |
||||
} |
||||
|
||||
func (m *manager) WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) { |
||||
t := m.getOrCreateTimeline(timelineAccountID) |
||||
|
||||
return t.Remove(statusID) |
||||
} |
||||
|
||||
func (m *manager) WipeStatusFromAllTimelines(statusID string) error { |
||||
errors := []string{} |
||||
m.accountTimelines.Range(func(k interface{}, i interface{}) bool { |
||||
t, ok := i.(Timeline) |
||||
if !ok { |
||||
panic("couldn't parse entry as Timeline, this should never happen so panic") |
||||
} |
||||
|
||||
if _, err := t.Remove(statusID); err != nil { |
||||
errors = append(errors, err.Error()) |
||||
} |
||||
|
||||
return false |
||||
}) |
||||
|
||||
var err error |
||||
if len(errors) > 0 { |
||||
err = fmt.Errorf("one or more errors removing status %s from all timelines: %s", statusID, strings.Join(errors, ";")) |
||||
} |
||||
|
||||
return err |
||||
} |
||||
|
||||
func (m *manager) getOrCreateTimeline(timelineAccountID string) Timeline { |
||||
var t Timeline |
||||
i, ok := m.accountTimelines.Load(timelineAccountID) |
||||
if !ok { |
||||
t = NewTimeline(timelineAccountID, m.db, m.tc, m.log) |
||||
m.accountTimelines.Store(timelineAccountID, t) |
||||
} else { |
||||
t, ok = i.(Timeline) |
||||
if !ok { |
||||
panic("couldn't parse entry as Timeline, this should never happen so panic") |
||||
} |
||||
} |
||||
|
||||
return t |
||||
} |
||||
@ -0,0 +1,57 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"container/list" |
||||
"errors" |
||||
) |
||||
|
||||
type postIndex struct { |
||||
data *list.List |
||||
} |
||||
|
||||
type postIndexEntry struct { |
||||
statusID string |
||||
} |
||||
|
||||
func (p *postIndex) insertIndexed(i *postIndexEntry) error { |
||||
if p.data == nil { |
||||
p.data = &list.List{} |
||||
} |
||||
|
||||
// if we have no entries yet, this is both the newest and oldest entry, so just put it in the front
|
||||
if p.data.Len() == 0 { |
||||
p.data.PushFront(i) |
||||
return nil |
||||
} |
||||
|
||||
var insertMark *list.Element |
||||
// We need to iterate through the index to make sure we put this post in the appropriate place according to when it was created.
|
||||
// We also need to make sure we're not inserting a duplicate post -- this can happen sometimes and it's not nice UX (*shudder*).
|
||||
for e := p.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return errors.New("index: could not parse e as a postIndexEntry") |
||||
} |
||||
|
||||
// if the post to index is newer than e, insert it before e in the list
|
||||
if insertMark == nil { |
||||
if i.statusID > entry.statusID { |
||||
insertMark = e |
||||
} |
||||
} |
||||
|
||||
// make sure we don't insert a duplicate
|
||||
if entry.statusID == i.statusID { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
if insertMark != nil { |
||||
p.data.InsertBefore(i, insertMark) |
||||
return nil |
||||
} |
||||
|
||||
// if we reach this point it's the oldest post we've seen so put it at the back
|
||||
p.data.PushBack(i) |
||||
return nil |
||||
} |
||||
@ -0,0 +1,215 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
) |
||||
|
||||
func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) error { |
||||
var err error |
||||
|
||||
// maxID is defined but sinceID isn't so take from behind
|
||||
if maxID != "" && sinceID == "" { |
||||
err = t.PrepareBehind(maxID, amount) |
||||
} |
||||
|
||||
// maxID isn't defined, but sinceID || minID are, so take x before
|
||||
if maxID == "" && sinceID != "" { |
||||
err = t.PrepareBefore(sinceID, false, amount) |
||||
} |
||||
if maxID == "" && minID != "" { |
||||
err = t.PrepareBefore(minID, false, amount) |
||||
} |
||||
|
||||
return err |
||||
} |
||||
|
||||
func (t *timeline) PrepareBehind(statusID string, amount int) error { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
|
||||
var prepared int |
||||
var preparing bool |
||||
prepareloop: |
||||
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return errors.New("PrepareBehind: could not parse e as a postIndexEntry") |
||||
} |
||||
|
||||
if !preparing { |
||||
// we haven't hit the position we need to prepare from yet
|
||||
if entry.statusID == statusID { |
||||
preparing = true |
||||
} |
||||
} |
||||
|
||||
if preparing { |
||||
if err := t.prepare(entry.statusID); err != nil { |
||||
// there's been an error
|
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
// it's a real error
|
||||
return fmt.Errorf("PrepareBehind: error preparing status with id %s: %s", entry.statusID, err) |
||||
} |
||||
// the status just doesn't exist (anymore) so continue to the next one
|
||||
continue |
||||
} |
||||
if prepared == amount { |
||||
// we're done
|
||||
break prepareloop |
||||
} |
||||
prepared = prepared + 1 |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) PrepareBefore(statusID string, include bool, amount int) error { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
|
||||
var prepared int |
||||
var preparing bool |
||||
prepareloop: |
||||
for e := t.postIndex.data.Back(); e != nil; e = e.Prev() { |
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return errors.New("PrepareBefore: could not parse e as a postIndexEntry") |
||||
} |
||||
|
||||
if !preparing { |
||||
// we haven't hit the position we need to prepare from yet
|
||||
if entry.statusID == statusID { |
||||
preparing = true |
||||
if !include { |
||||
continue |
||||
} |
||||
} |
||||
} |
||||
|
||||
if preparing { |
||||
if err := t.prepare(entry.statusID); err != nil { |
||||
// there's been an error
|
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
// it's a real error
|
||||
return fmt.Errorf("PrepareBefore: error preparing status with id %s: %s", entry.statusID, err) |
||||
} |
||||
// the status just doesn't exist (anymore) so continue to the next one
|
||||
continue |
||||
} |
||||
if prepared == amount { |
||||
// we're done
|
||||
break prepareloop |
||||
} |
||||
prepared = prepared + 1 |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) PrepareFromTop(amount int) error { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
|
||||
t.preparedPosts.data.Init() |
||||
|
||||
var prepared int |
||||
prepareloop: |
||||
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return errors.New("PrepareFromTop: could not parse e as a postIndexEntry") |
||||
} |
||||
|
||||
if err := t.prepare(entry.statusID); err != nil { |
||||
// there's been an error
|
||||
if _, ok := err.(db.ErrNoEntries); !ok { |
||||
// it's a real error
|
||||
return fmt.Errorf("PrepareFromTop: error preparing status with id %s: %s", entry.statusID, err) |
||||
} |
||||
// the status just doesn't exist (anymore) so continue to the next one
|
||||
continue |
||||
} |
||||
|
||||
prepared = prepared + 1 |
||||
if prepared == amount { |
||||
// we're done
|
||||
break prepareloop |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) prepare(statusID string) error { |
||||
|
||||
// start by getting the status out of the database according to its indexed ID
|
||||
gtsStatus := >smodel.Status{} |
||||
if err := t.db.GetByID(statusID, gtsStatus); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// if the account pointer hasn't been set on this timeline already, set it lazily here
|
||||
if t.account == nil { |
||||
timelineOwnerAccount := >smodel.Account{} |
||||
if err := t.db.GetByID(t.accountID, timelineOwnerAccount); err != nil { |
||||
return err |
||||
} |
||||
t.account = timelineOwnerAccount |
||||
} |
||||
|
||||
// to convert the status we need relevant accounts from it, so pull them out here
|
||||
relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(gtsStatus) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// check if this is a boost...
|
||||
var reblogOfStatus *gtsmodel.Status |
||||
if gtsStatus.BoostOfID != "" { |
||||
s := >smodel.Status{} |
||||
if err := t.db.GetByID(gtsStatus.BoostOfID, s); err != nil { |
||||
return err |
||||
} |
||||
reblogOfStatus = s |
||||
} |
||||
|
||||
// serialize the status (or, at least, convert it to a form that's ready to be serialized)
|
||||
apiModelStatus, err := t.tc.StatusToMasto(gtsStatus, relevantAccounts.StatusAuthor, t.account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, reblogOfStatus) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// shove it in prepared posts as a prepared posts entry
|
||||
preparedPostsEntry := &preparedPostsEntry{ |
||||
statusID: statusID, |
||||
prepared: apiModelStatus, |
||||
} |
||||
|
||||
return t.preparedPosts.insertPrepared(preparedPostsEntry) |
||||
} |
||||
|
||||
func (t *timeline) OldestPreparedPostID() (string, error) { |
||||
var id string |
||||
if t.preparedPosts == nil || t.preparedPosts.data == nil { |
||||
// return an empty string if prepared posts hasn't been initialized yet
|
||||
return id, nil |
||||
} |
||||
|
||||
e := t.preparedPosts.data.Back() |
||||
if e == nil { |
||||
// return an empty string if there's no back entry (ie., the index list hasn't been initialized yet)
|
||||
return id, nil |
||||
} |
||||
|
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return id, errors.New("OldestPreparedPostID: could not parse e as a preparedPostsEntry") |
||||
} |
||||
return entry.statusID, nil |
||||
} |
||||
@ -0,0 +1,60 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"container/list" |
||||
"errors" |
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
) |
||||
|
||||
type preparedPosts struct { |
||||
data *list.List |
||||
} |
||||
|
||||
type preparedPostsEntry struct { |
||||
statusID string |
||||
prepared *apimodel.Status |
||||
} |
||||
|
||||
func (p *preparedPosts) insertPrepared(i *preparedPostsEntry) error { |
||||
if p.data == nil { |
||||
p.data = &list.List{} |
||||
} |
||||
|
||||
// if we have no entries yet, this is both the newest and oldest entry, so just put it in the front
|
||||
if p.data.Len() == 0 { |
||||
p.data.PushFront(i) |
||||
return nil |
||||
} |
||||
|
||||
var insertMark *list.Element |
||||
// We need to iterate through the index to make sure we put this post in the appropriate place according to when it was created.
|
||||
// We also need to make sure we're not inserting a duplicate post -- this can happen sometimes and it's not nice UX (*shudder*).
|
||||
for e := p.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return errors.New("index: could not parse e as a preparedPostsEntry") |
||||
} |
||||
|
||||
// if the post to index is newer than e, insert it before e in the list
|
||||
if insertMark == nil { |
||||
if i.statusID > entry.statusID { |
||||
insertMark = e |
||||
} |
||||
} |
||||
|
||||
// make sure we don't insert a duplicate
|
||||
if entry.statusID == i.statusID { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
if insertMark != nil { |
||||
p.data.InsertBefore(i, insertMark) |
||||
return nil |
||||
} |
||||
|
||||
// if we reach this point it's the oldest post we've seen so put it at the back
|
||||
p.data.PushBack(i) |
||||
return nil |
||||
} |
||||
@ -0,0 +1,50 @@
|
||||
package timeline |
||||
|
||||
import ( |
||||
"container/list" |
||||
"errors" |
||||
) |
||||
|
||||
func (t *timeline) Remove(statusID string) (int, error) { |
||||
t.Lock() |
||||
defer t.Unlock() |
||||
var removed int |
||||
|
||||
// remove entr(ies) from the post index
|
||||
removeIndexes := []*list.Element{} |
||||
if t.postIndex != nil && t.postIndex.data != nil { |
||||
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*postIndexEntry) |
||||
if !ok { |
||||
return removed, errors.New("Remove: could not parse e as a postIndexEntry") |
||||
} |
||||
if entry.statusID == statusID { |
||||
removeIndexes = append(removeIndexes, e) |
||||
} |
||||
} |
||||
} |
||||
for _, e := range removeIndexes { |
||||
t.postIndex.data.Remove(e) |
||||
removed = removed + 1 |
||||
} |
||||
|
||||
// remove entr(ies) from prepared posts
|
||||
removePrepared := []*list.Element{} |
||||
if t.preparedPosts != nil && t.preparedPosts.data != nil { |
||||
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { |
||||
entry, ok := e.Value.(*preparedPostsEntry) |
||||
if !ok { |
||||
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") |
||||
} |
||||
if entry.statusID == statusID { |
||||
removePrepared = append(removePrepared, e) |
||||
} |
||||
} |
||||
} |
||||
for _, e := range removePrepared { |
||||
t.preparedPosts.data.Remove(e) |
||||
removed = removed + 1 |
||||
} |
||||
|
||||
return removed, nil |
||||
} |
||||
@ -0,0 +1,139 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org |
||||
|
||||
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" |
||||
"time" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" |
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils" |
||||
) |
||||
|
||||
// Timeline represents a timeline for one account, and contains indexed and prepared posts.
|
||||
type Timeline interface { |
||||
/* |
||||
RETRIEVAL FUNCTIONS |
||||
*/ |
||||
|
||||
Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) |
||||
// GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest.
|
||||
GetXFromTop(amount int) ([]*apimodel.Status, error) |
||||
// GetXBehindID returns x amount of posts from the given id onwards, from newest to oldest.
|
||||
// This will NOT include the status with the given ID.
|
||||
//
|
||||
// This corresponds to an api call to /timelines/home?max_id=WHATEVER
|
||||
GetXBehindID(amount int, fromID string) ([]*apimodel.Status, error) |
||||
// GetXBeforeID returns x amount of posts up to the given id, from newest to oldest.
|
||||
// This will NOT include the status with the given ID.
|
||||
//
|
||||
// This corresponds to an api call to /timelines/home?since_id=WHATEVER
|
||||
GetXBeforeID(amount int, sinceID string, startFromTop bool) ([]*apimodel.Status, error) |
||||
// GetXBetweenID returns x amount of posts from the given maxID, up to the given id, from newest to oldest.
|
||||
// This will NOT include the status with the given IDs.
|
||||
//
|
||||
// This corresponds to an api call to /timelines/home?since_id=WHATEVER&max_id=WHATEVER_ELSE
|
||||
GetXBetweenID(amount int, maxID string, sinceID string) ([]*apimodel.Status, error) |
||||
|
||||
/* |
||||
INDEXING FUNCTIONS |
||||
*/ |
||||
|
||||
// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property.
|
||||
IndexOne(statusCreatedAt time.Time, statusID string) error |
||||
|
||||
// OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong.
|
||||
// If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this.
|
||||
OldestIndexedPostID() (string, error) |
||||
|
||||
/* |
||||
PREPARATION FUNCTIONS |
||||
*/ |
||||
|
||||
// PrepareXFromTop instructs the timeline to prepare x amount of posts from the top of the timeline.
|
||||
PrepareFromTop(amount int) error |
||||
// PrepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards.
|
||||
// If include is true, then the given status ID will also be prepared, otherwise only entries behind it will be prepared.
|
||||
PrepareBehind(statusID string, amount int) error |
||||
// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property,
|
||||
// and then immediately prepares it.
|
||||
IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error |
||||
// OldestPreparedPostID returns the id of the rearmost (ie., the oldest) prepared post, or an error if something goes wrong.
|
||||
// If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this.
|
||||
OldestPreparedPostID() (string, error) |
||||
|
||||
/* |
||||
INFO FUNCTIONS |
||||
*/ |
||||
|
||||
// ActualPostIndexLength returns the actual length of the post index at this point in time.
|
||||
PostIndexLength() int |
||||
|
||||
/* |
||||
UTILITY FUNCTIONS |
||||
*/ |
||||
|
||||
// Reset instructs the timeline to reset to its base state -- cache only the minimum amount of posts.
|
||||
Reset() error |
||||
// Remove removes a status from both the index and prepared posts.
|
||||
//
|
||||
// If a status has multiple entries in a timeline, they will all be removed.
|
||||
//
|
||||
// The returned int indicates the amount of entries that were removed.
|
||||
Remove(statusID string) (int, error) |
||||
} |
||||
|
||||
// timeline fulfils the Timeline interface
|
||||
type timeline struct { |
||||
postIndex *postIndex |
||||
preparedPosts *preparedPosts |
||||
accountID string |
||||
account *gtsmodel.Account |
||||
db db.DB |
||||
tc typeutils.TypeConverter |
||||
log *logrus.Logger |
||||
sync.Mutex |
||||
} |
||||
|
||||
// NewTimeline returns a new Timeline for the given account ID
|
||||
func NewTimeline(accountID string, db db.DB, typeConverter typeutils.TypeConverter, log *logrus.Logger) Timeline { |
||||
return &timeline{ |
||||
postIndex: &postIndex{}, |
||||
preparedPosts: &preparedPosts{}, |
||||
accountID: accountID, |
||||
db: db, |
||||
tc: typeConverter, |
||||
log: log, |
||||
} |
||||
} |
||||
|
||||
func (t *timeline) Reset() error { |
||||
return nil |
||||
} |
||||
|
||||
func (t *timeline) PostIndexLength() int { |
||||
if t.postIndex == nil || t.postIndex.data == nil { |
||||
return 0 |
||||
} |
||||
|
||||
return t.postIndex.data.Len() |
||||
} |
||||
@ -0,0 +1,11 @@
|
||||
package testrig |
||||
|
||||
import ( |
||||
"github.com/superseriousbusiness/gotosocial/internal/db" |
||||
"github.com/superseriousbusiness/gotosocial/internal/timeline" |
||||
) |
||||
|
||||
// NewTestTimelineManager retuts a new timeline.Manager, suitable for testing, using the given db.
|
||||
func NewTestTimelineManager(db db.DB) timeline.Manager { |
||||
return timeline.NewManager(db, NewTestTypeConverter(db), NewTestConfig(), NewTestLog()) |
||||
} |
||||
Loading…
Reference in new issue