[feature] Add paging via Link header for notifications and account statuses (#629)

* test link headers

* page get account statuses properly

* page get notifications

* add util func for packaging timeline responses

* return timelined stuff from accountstatusesget

* rename timeline response

* use new convenience function

* go fmt
This commit is contained in:
tobi 2022-06-08 20:22:49 +02:00 committed by GitHub
parent 99fab9c6b6
commit 6f6e89e271
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 303 additions and 98 deletions

View file

@ -222,12 +222,15 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
publicOnly = i
}
statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
resp, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
if errWithCode != nil {
l.Debugf("error from processor account statuses get: %s", errWithCode)
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
c.JSON(http.StatusOK, statuses)
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -37,7 +37,47 @@ type AccountStatusesTestSuite struct {
AccountStandardTestSuite
}
func (suite *AccountStatusesTestSuite) TestGetStatusesMediaOnly() {
func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnly() {
// set up the request
// we're getting statuses of admin
targetAccount := suite.testAccounts["admin_account"]
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodGet, nil, fmt.Sprintf("/api/v1/accounts/%s/statuses?limit=20&only_media=false&only_public=true", targetAccount.ID), "")
ctx.Params = gin.Params{
gin.Param{
Key: account.IDKey,
Value: targetAccount.ID,
},
}
// call the handler
suite.accountModule.AccountStatusesGETHandler(ctx)
// 1. we should have OK because our request was valid
suite.Equal(http.StatusOK, recorder.Code)
// 2. we should have no error message in the result body
result := recorder.Result()
defer result.Body.Close()
// check the response
b, err := ioutil.ReadAll(result.Body)
assert.NoError(suite.T(), err)
// unmarshal the returned statuses
apimodelStatuses := []*apimodel.Status{}
err = json.Unmarshal(b, &apimodelStatuses)
suite.NoError(err)
suite.NotEmpty(apimodelStatuses)
for _, s := range apimodelStatuses {
suite.Equal(apimodel.VisibilityPublic, s.Visibility)
}
suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01G36SF3V6Y6V5BF9P4R7PQG7G&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=false&only_public=true>; rel="prev"`, result.Header.Get("link"))
}
func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnlyMediaOnly() {
// set up the request
// we're getting statuses of admin
targetAccount := suite.testAccounts["admin_account"]
@ -74,6 +114,8 @@ func (suite *AccountStatusesTestSuite) TestGetStatusesMediaOnly() {
suite.NotEmpty(s.MediaAttachments)
suite.Equal(apimodel.VisibilityPublic, s.Visibility)
}
suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned_only=false&only_media=true&only_public=true>; rel="prev"`, result.Header.Get("link"))
}
func TestAccountStatusesTestSuite(t *testing.T) {

View file

@ -61,5 +61,5 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) {
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Statuses)
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -74,12 +74,15 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) {
sinceID = sinceIDString
}
notifs, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, limit, maxID, sinceID)
resp, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, limit, maxID, sinceID)
if errWithCode != nil {
l.Debugf("error processing notifications get: %s", errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
c.JSON(http.StatusOK, notifs)
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -171,5 +171,5 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) {
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Statuses)
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -171,5 +171,5 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) {
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
c.JSON(http.StatusOK, resp.Statuses)
c.JSON(http.StatusOK, resp.Items)
}

View file

@ -43,3 +43,24 @@ type Notification struct {
// Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.
Status *Status `json:"status,omitempty"`
}
/*
The below functions are added onto the apimodel notification so that it satisfies
the Timelineable interface in internal/timeline.
*/
func (n *Notification) GetID() string {
return n.ID
}
func (n *Notification) GetAccountID() string {
return ""
}
func (n *Notification) GetBoostOfID() string {
return ""
}
func (n *Notification) GetBoostOfAccountID() string {
return ""
}

View file

@ -18,9 +18,11 @@
package model
// StatusTimelineResponse wraps a slice of statuses, ready to be serialized, along with the Link
import "github.com/superseriousbusiness/gotosocial/internal/timeline"
// TimelineResponse wraps a slice of timelineables, 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
type TimelineResponse struct {
Items []timeline.Timelineable
LinkHeader string
}

View file

@ -46,7 +46,7 @@ func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form
return p.accountProcessor.Update(ctx, authed.Account, form)
}
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) {
return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
}

View file

@ -55,7 +55,7 @@ type Processor interface {
Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
// the account given in authed.
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode)
// FollowersGet fetches a list of the target account's followers.
FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// FollowingGet fetches a list of the accounts that target account is following.

View file

@ -26,9 +26,11 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) {
if requestingAccount != nil {
if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil {
return nil, gtserror.NewErrorInternalError(err)
@ -37,29 +39,48 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
}
}
apiStatuses := []apimodel.Status{}
statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
if err != nil {
if err == db.ErrNoEntries {
return apiStatuses, nil
return util.EmptyTimelineResponse(), nil
}
return nil, gtserror.NewErrorInternalError(err)
}
var filtered []*gtsmodel.Status
for _, s := range statuses {
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
if err != nil || !visible {
continue
if err == nil && visible {
filtered = append(filtered, s)
}
}
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount)
if len(filtered) == 0 {
return util.EmptyTimelineResponse(), nil
}
timelineables := []timeline.Timelineable{}
for _, i := range filtered {
apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, requestingAccount)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
}
apiStatuses = append(apiStatuses, *apiStatus)
timelineables = append(timelineables, apiStatus)
}
return apiStatuses, nil
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
Items: timelineables,
Path: fmt.Sprintf("/api/v1/accounts/%s/statuses", targetAccountID),
NextMaxIDValue: timelineables[len(timelineables)-1].GetID(),
PrevMinIDValue: timelineables[0].GetID(),
Limit: limit,
ExtraQueryParams: []string{
fmt.Sprintf("exclude_replies=%t", excludeReplies),
fmt.Sprintf("exclude_reblogs=%t", excludeReblogs),
fmt.Sprintf("pinned_only=%t", pinnedOnly),
fmt.Sprintf("only_media=%t", mediaOnly),
fmt.Sprintf("only_public=%t", publicOnly),
},
})
}

View file

@ -26,9 +26,11 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode) {
func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode) {
l := logrus.WithField("func", "NotificationsGet")
notifs, err := p.db.GetNotifications(ctx, authed.Account.ID, limit, maxID, sinceID)
@ -36,15 +38,26 @@ func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, li
return nil, gtserror.NewErrorInternalError(err)
}
apiNotifs := []*apimodel.Notification{}
if len(notifs) == 0 {
return util.EmptyTimelineResponse(), nil
}
timelineables := []timeline.Timelineable{}
for _, n := range notifs {
apiNotif, err := p.tc.NotificationToAPINotification(ctx, n)
if err != nil {
l.Debugf("got an error converting a notification to api, will skip it: %s", err)
continue
}
apiNotifs = append(apiNotifs, apiNotif)
timelineables = append(timelineables, apiNotif)
}
return apiNotifs, nil
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
Items: timelineables,
Path: "api/v1/notifications",
NextMaxIDValue: timelineables[len(timelineables)-1].GetID(),
PrevMinIDKey: "since_id",
PrevMinIDValue: timelineables[0].GetID(),
Limit: limit,
})
}

View file

@ -23,6 +23,7 @@ import (
"testing"
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
)
type NotificationTestSuite struct {
@ -32,14 +33,19 @@ type NotificationTestSuite struct {
// get a notification where someone has liked our status
func (suite *NotificationTestSuite) TestGetNotifications() {
receivingAccount := suite.testAccounts["local_account_1"]
notifs, err := suite.processor.NotificationsGet(context.Background(), suite.testAutheds["local_account_1"], 10, "", "")
notifsResponse, err := suite.processor.NotificationsGet(context.Background(), suite.testAutheds["local_account_1"], 10, "", "")
suite.NoError(err)
suite.Len(notifs, 1)
notif := notifs[0]
suite.Len(notifsResponse.Items, 1)
notif, ok := notifsResponse.Items[0].(*apimodel.Notification)
if !ok {
panic("notif in response wasn't *apimodel.Notification")
}
suite.NotNil(notif.Status)
suite.NotNil(notif.Status)
suite.NotNil(notif.Status.Account)
suite.Equal(receivingAccount.ID, notif.Status.Account.ID)
suite.Equal(`<http://localhost:8080/api/v1/notifications?limit=10&max_id=01F8Q0ANPTWW10DAKTX7BRPBJP>; rel="next", <http://localhost:8080/api/v1/notifications?limit=10&since_id=01F8Q0ANPTWW10DAKTX7BRPBJP>; rel="prev"`, notifsResponse.LinkHeader)
}
func TestNotificationTestSuite(t *testing.T) {

View file

@ -83,7 +83,7 @@ type Processor interface {
AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
// the account given in authed.
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode)
// AccountFollowersGet fetches a list of the target account's followers.
AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// AccountFollowingGet fetches a list of the accounts that target account is following.
@ -150,7 +150,7 @@ type Processor interface {
MediaUpdate(ctx context.Context, authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode)
// NotificationsGet
NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) ([]*apimodel.Notification, gtserror.WithCode)
NotificationsGet(ctx context.Context, authed *oauth.Auth, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode)
// SearchGet performs a search with the given params, resolving/dereferencing remotely as desired
SearchGet(ctx context.Context, authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode)
@ -177,11 +177,11 @@ type Processor interface {
StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode)
// HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters.
HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode)
HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode)
// PublicTimelineGet returns statuses from the public/local timeline, with the given filters/parameters.
PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode)
PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode)
// FavedTimelineGet returns faved statuses, with the given filters/parameters.
FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode)
FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode)
// AuthorizeStreamingRequest returns a gotosocial account in exchange for an access token, or an error if the given token is not valid.
AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error)

View file

@ -22,18 +22,17 @@ import (
"context"
"errors"
"fmt"
"net/url"
"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/oauth"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
)
@ -139,114 +138,100 @@ func StatusSkipInsertFunction() timeline.SkipInsertFunction {
}
}
func (p *processor) packageStatusResponse(statuses []*apimodel.Status, path string, nextMaxID string, prevMinID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
resp := &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}
resp.Statuses = statuses
// prepare the next and previous links
if len(statuses) != 0 {
protocol := config.GetProtocol()
host := config.GetHost()
nextLink := &url.URL{
Scheme: protocol,
Host: host,
Path: path,
RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, nextMaxID),
}
next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String())
prevLink := &url.URL{
Scheme: protocol,
Host: host,
Path: path,
RawQuery: fmt.Sprintf("limit=%d&min_id=%s", limit, prevMinID),
}
prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String())
resp.LinkHeader = fmt.Sprintf("%s, %s", next, prev)
}
return resp, nil
}
func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) {
preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if len(preparedItems) == 0 {
return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}, nil
return util.EmptyTimelineResponse(), nil
}
statuses := []*apimodel.Status{}
timelineables := []timeline.Timelineable{}
for _, i := range preparedItems {
status, ok := i.(*apimodel.Status)
if !ok {
return nil, gtserror.NewErrorInternalError(errors.New("error converting prepared timeline entry to api status"))
}
statuses = append(statuses, status)
timelineables = append(timelineables, status)
}
return p.packageStatusResponse(statuses, "api/v1/timelines/home", statuses[len(preparedItems)-1].ID, statuses[0].ID, limit)
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
Items: timelineables,
Path: "api/v1/timelines/home",
NextMaxIDValue: timelineables[len(timelineables)-1].GetID(),
PrevMinIDValue: timelineables[0].GetID(),
Limit: limit,
})
}
func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) {
statuses, err := p.db.GetPublicTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil {
if err == db.ErrNoEntries {
// there are just no entries left
return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}, nil
return util.EmptyTimelineResponse(), nil
}
// there's an actual error
return nil, gtserror.NewErrorInternalError(err)
}
s, err := p.filterPublicStatuses(ctx, authed, statuses)
filtered, err := p.filterPublicStatuses(ctx, authed, statuses)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if len(s) == 0 {
return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}, nil
if len(filtered) == 0 {
return util.EmptyTimelineResponse(), nil
}
return p.packageStatusResponse(s, "api/v1/timelines/public", s[len(s)-1].ID, s[0].ID, limit)
timelineables := []timeline.Timelineable{}
for _, i := range filtered {
timelineables = append(timelineables, i)
}
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
Items: timelineables,
Path: "api/v1/timelines/public",
NextMaxIDValue: timelineables[len(timelineables)-1].GetID(),
PrevMinIDValue: timelineables[0].GetID(),
Limit: limit,
})
}
func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) {
statuses, nextMaxID, prevMinID, err := p.db.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
if err != nil {
if err == db.ErrNoEntries {
// there are just no entries left
return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}, nil
return util.EmptyTimelineResponse(), nil
}
// there's an actual error
return nil, gtserror.NewErrorInternalError(err)
}
s, err := p.filterFavedStatuses(ctx, authed, statuses)
filtered, err := p.filterFavedStatuses(ctx, authed, statuses)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if len(s) == 0 {
return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{},
}, nil
if len(filtered) == 0 {
return util.EmptyTimelineResponse(), nil
}
return p.packageStatusResponse(s, "api/v1/favourites", nextMaxID, prevMinID, limit)
timelineables := []timeline.Timelineable{}
for _, i := range filtered {
timelineables = append(timelineables, i)
}
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
Items: timelineables,
Path: "api/v1/favourites",
NextMaxIDValue: nextMaxID,
PrevMinIDValue: prevMinID,
Limit: limit,
})
}
func (p *processor) filterPublicStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) {

104
internal/util/timeline.go Normal file
View file

@ -0,0 +1,104 @@
/*
GoToSocial
Copyright (C) 2021-2022 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 util
import (
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
)
// TimelineableResponseParams models the parameters to pass to PackageTimelineableResponse.
//
// The given items will be provided in the timeline response.
//
// The other values are all used to create the Link header so that callers know
// which endpoint to query next and previously in order to do paging.
type TimelineableResponseParams struct {
Items []timeline.Timelineable // Sorted slice of Timelineables (statuses, notifications, etc)
Path string // path to use for next/prev queries in the link header
NextMaxIDKey string // key to use for the next max id query param in the link header, defaults to 'max_id'
NextMaxIDValue string // value to use for next max id
PrevMinIDKey string // key to use for the prev min id query param in the link header, defaults to 'min_id'
PrevMinIDValue string // value to use for prev min id
Limit int // limit number of entries to return
ExtraQueryParams []string // any extra query parameters to provide in the link header, should be in the format 'example=value'
}
// PackageTimelineableResponse is a convenience function for returning
// a bunch of timelineable items (notifications, statuses, etc), as well
// as a Link header to inform callers of where to find next/prev items.
func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.TimelineResponse, gtserror.WithCode) {
if params.NextMaxIDKey == "" {
params.NextMaxIDKey = "max_id"
}
if params.PrevMinIDKey == "" {
params.PrevMinIDKey = "min_id"
}
timelineResponse := &apimodel.TimelineResponse{
Items: params.Items,
}
// prepare the next and previous links
if len(params.Items) != 0 {
protocol := config.GetProtocol()
host := config.GetHost()
nextRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.NextMaxIDKey, params.NextMaxIDValue)
for _, p := range params.ExtraQueryParams {
nextRaw = nextRaw + "&" + p
}
nextLink := &url.URL{
Scheme: protocol,
Host: host,
Path: params.Path,
RawQuery: nextRaw,
}
next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String())
prevRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.PrevMinIDKey, params.PrevMinIDValue)
for _, p := range params.ExtraQueryParams {
prevRaw = prevRaw + "&" + p
}
prevLink := &url.URL{
Scheme: protocol,
Host: host,
Path: params.Path,
RawQuery: prevRaw,
}
prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String())
timelineResponse.LinkHeader = next + ", " + prev
}
return timelineResponse, nil
}
// EmptyTimelineResponse just returns an empty
// TimelineResponse with no link header or items.
func EmptyTimelineResponse() *apimodel.TimelineResponse {
return &apimodel.TimelineResponse{
Items: []timeline.Timelineable{},
}
}

View file

@ -29,6 +29,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/api"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
@ -79,7 +80,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) {
// get latest 10 top-level public statuses;
// ie., exclude replies and boosts, public only,
// with or without media
statuses, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true)
statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true)
if errWithCode != nil {
l.Debugf("error getting statuses from processor: %s", errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
@ -92,7 +93,11 @@ func (m *Module) profileTemplateHandler(c *gin.Context) {
randomIndex := rand.Intn(len(m.defaultAvatars))
dummyAvatar := m.defaultAvatars[randomIndex]
account.Avatar = dummyAvatar
for _, s := range statuses {
for _, i := range statusResp.Items {
s, ok := i.(*apimodel.Status)
if !ok {
panic("timelineable was not *apimodel.Status")
}
s.Account.Avatar = dummyAvatar
}
}
@ -100,7 +105,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) {
c.HTML(http.StatusOK, "profile.tmpl", gin.H{
"instance": instance,
"account": account,
"statuses": statuses,
"statuses": statusResp.Items,
"stylesheets": []string{
"/assets/Fork-Awesome/css/fork-awesome.min.css",
"/assets/status.css",