mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 15:42:25 -05:00 
			
		
		
		
	[chore] Make paging logic more generic (#901)
* make paging logic more generic not just for timelines! * linty linterson
This commit is contained in:
		
					parent
					
						
							
								80663061d8
							
						
					
				
			
			
				commit
				
					
						832befd727
					
				
			
		
					 9 changed files with 257 additions and 200 deletions
				
			
		|  | @ -18,12 +18,10 @@ | ||||||
| 
 | 
 | ||||||
| package model | package model | ||||||
| 
 | 
 | ||||||
| import "github.com/superseriousbusiness/gotosocial/internal/timeline" | // PageableResponse wraps a slice of items, ready to be serialized, along with the Link | ||||||
| 
 | // header for the previous and next queries / pages, to be returned to the client. | ||||||
| // TimelineResponse wraps a slice of timelineables, ready to be serialized, along with the Link | type PageableResponse struct { | ||||||
| // header for the previous and next queries, to be returned to the client. | 	Items      []interface{} | ||||||
| type TimelineResponse struct { |  | ||||||
| 	Items      []timeline.Timelineable |  | ||||||
| 	LinkHeader string | 	LinkHeader string | ||||||
| 	NextLink   string | 	NextLink   string | ||||||
| 	PrevLink   string | 	PrevLink   string | ||||||
|  | @ -55,11 +55,11 @@ func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form | ||||||
| 	return p.accountProcessor.Update(ctx, authed.Account, 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.TimelineResponse, 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.PageableResponse, gtserror.WithCode) { | ||||||
| 	return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) | 	return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *processor) AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	return p.accountProcessor.WebStatusesGet(ctx, targetAccountID, maxID) | 	return p.accountProcessor.WebStatusesGet(ctx, targetAccountID, maxID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -60,10 +60,10 @@ type Processor interface { | ||||||
| 	Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) | 	Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) | ||||||
| 	// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for | 	// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for | ||||||
| 	// the account given in authed. | 	// 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.TimelineResponse, 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.PageableResponse, gtserror.WithCode) | ||||||
| 	// WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only | 	// WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only | ||||||
| 	// statuses which are suitable for showing on the public web profile of an account. | 	// statuses which are suitable for showing on the public web profile of an account. | ||||||
| 	WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) | 	WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 	// FollowersGet fetches a list of the target account's followers. | 	// FollowersGet fetches a list of the target account's followers. | ||||||
| 	FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) | 	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. | 	// FollowingGet fetches a list of the accounts that target account is following. | ||||||
|  |  | ||||||
|  | @ -26,11 +26,10 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/timeline" |  | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/util" | 	"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.TimelineResponse, 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.PageableResponse, gtserror.WithCode) { | ||||||
| 	if requestingAccount != nil { | 	if requestingAccount != nil { | ||||||
| 		if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { | 		if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil { | ||||||
| 			return nil, gtserror.NewErrorInternalError(err) | 			return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -42,7 +41,7 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel | ||||||
| 	statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) | 	statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == db.ErrNoEntries { | 		if err == db.ErrNoEntries { | ||||||
| 			return util.EmptyTimelineResponse(), nil | 			return util.EmptyPageableResponse(), nil | ||||||
| 		} | 		} | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  | @ -55,25 +54,37 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if len(filtered) == 0 { | 	count := len(filtered) | ||||||
| 		return util.EmptyTimelineResponse(), nil | 
 | ||||||
|  | 	if count == 0 { | ||||||
|  | 		return util.EmptyPageableResponse(), nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	items := []interface{}{} | ||||||
| 	for _, i := range filtered { | 	nextMaxIDValue := "" | ||||||
| 		apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, requestingAccount) | 	prevMinIDValue := "" | ||||||
|  | 	for i, s := range filtered { | ||||||
|  | 		item, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) | 			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		timelineables = append(timelineables, apiStatus) | 		if i == count-1 { | ||||||
|  | 			nextMaxIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if i == 0 { | ||||||
|  | 			prevMinIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:          timelineables, | 		Items:          items, | ||||||
| 		Path:           fmt.Sprintf("/api/v1/accounts/%s/statuses", targetAccountID), | 		Path:           fmt.Sprintf("/api/v1/accounts/%s/statuses", targetAccountID), | ||||||
| 		NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), | 		NextMaxIDValue: nextMaxIDValue, | ||||||
| 		PrevMinIDValue: timelineables[0].GetID(), | 		PrevMinIDValue: prevMinIDValue, | ||||||
| 		Limit:          limit, | 		Limit:          limit, | ||||||
| 		ExtraQueryParams: []string{ | 		ExtraQueryParams: []string{ | ||||||
| 			fmt.Sprintf("exclude_replies=%t", excludeReplies), | 			fmt.Sprintf("exclude_replies=%t", excludeReplies), | ||||||
|  | @ -85,7 +96,7 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	acct, err := p.db.GetAccountByID(ctx, targetAccountID) | 	acct, err := p.db.GetAccountByID(ctx, targetAccountID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == db.ErrNoEntries { | 		if err == db.ErrNoEntries { | ||||||
|  | @ -103,26 +114,42 @@ func (p *processor) WebStatusesGet(ctx context.Context, targetAccountID string, | ||||||
| 	statuses, err := p.db.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID) | 	statuses, err := p.db.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == db.ErrNoEntries { | 		if err == db.ErrNoEntries { | ||||||
| 			return util.EmptyTimelineResponse(), nil | 			return util.EmptyPageableResponse(), nil | ||||||
| 		} | 		} | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	count := len(statuses) | ||||||
| 	for _, i := range statuses { | 
 | ||||||
| 		apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, nil) | 	if count == 0 { | ||||||
|  | 		return util.EmptyPageableResponse(), nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	items := []interface{}{} | ||||||
|  | 	nextMaxIDValue := "" | ||||||
|  | 	prevMinIDValue := "" | ||||||
|  | 	for i, s := range statuses { | ||||||
|  | 		item, err := p.tc.StatusToAPIStatus(ctx, s, nil) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) | 			return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err)) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		timelineables = append(timelineables, apiStatus) | 		if i == count-1 { | ||||||
|  | 			nextMaxIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if i == 0 { | ||||||
|  | 			prevMinIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:            timelineables, | 		Items:            items, | ||||||
| 		Path:             "/@" + acct.Username, | 		Path:             "/@" + acct.Username, | ||||||
| 		NextMaxIDValue:   timelineables[len(timelineables)-1].GetID(), | 		NextMaxIDValue:   nextMaxIDValue, | ||||||
| 		PrevMinIDValue:   timelineables[0].GetID(), | 		PrevMinIDValue:   prevMinIDValue, | ||||||
| 		ExtraQueryParams: []string{}, | 		ExtraQueryParams: []string{}, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -25,36 +25,48 @@ import ( | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | 	"github.com/superseriousbusiness/gotosocial/internal/oauth" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/timeline" |  | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/util" | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, excludeTypes []string, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, excludeTypes []string, limit int, maxID string, sinceID string) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	notifs, err := p.db.GetNotifications(ctx, authed.Account.ID, excludeTypes, limit, maxID, sinceID) | 	notifs, err := p.db.GetNotifications(ctx, authed.Account.ID, excludeTypes, limit, maxID, sinceID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if len(notifs) == 0 { | 	count := len(notifs) | ||||||
| 		return util.EmptyTimelineResponse(), nil | 
 | ||||||
|  | 	if count == 0 { | ||||||
|  | 		return util.EmptyPageableResponse(), nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	items := []interface{}{} | ||||||
| 	for _, n := range notifs { | 	nextMaxIDValue := "" | ||||||
| 		apiNotif, err := p.tc.NotificationToAPINotification(ctx, n) | 	prevMinIDValue := "" | ||||||
|  | 	for i, n := range notifs { | ||||||
|  | 		item, err := p.tc.NotificationToAPINotification(ctx, n) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			log.Debugf("got an error converting a notification to api, will skip it: %s", err) | 			log.Debugf("got an error converting a notification to api, will skip it: %s", err) | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 		timelineables = append(timelineables, apiNotif) | 
 | ||||||
|  | 		if i == count-1 { | ||||||
|  | 			nextMaxIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if i == 0 { | ||||||
|  | 			prevMinIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:          timelineables, | 		Items:          items, | ||||||
| 		Path:           "api/v1/notifications", | 		Path:           "api/v1/notifications", | ||||||
| 		NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), | 		NextMaxIDValue: nextMaxIDValue, | ||||||
| 		PrevMinIDKey:   "since_id", | 		PrevMinIDKey:   "since_id", | ||||||
| 		PrevMinIDValue: timelineables[0].GetID(), | 		PrevMinIDValue: prevMinIDValue, | ||||||
| 		Limit:          limit, | 		Limit:          limit, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -89,10 +89,10 @@ type Processor interface { | ||||||
| 	AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) | 	AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) | ||||||
| 	// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for | 	// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for | ||||||
| 	// the account given in authed. | 	// 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.TimelineResponse, 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.PageableResponse, gtserror.WithCode) | ||||||
| 	// AccountWebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only | 	// AccountWebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only | ||||||
| 	// statuses which are suitable for showing on the public web profile of an account. | 	// statuses which are suitable for showing on the public web profile of an account. | ||||||
| 	AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) | 	AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 	// AccountFollowersGet fetches a list of the target account's followers. | 	// AccountFollowersGet fetches a list of the target account's followers. | ||||||
| 	AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) | 	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. | 	// AccountFollowingGet fetches a list of the accounts that target account is following. | ||||||
|  | @ -160,7 +160,7 @@ type Processor interface { | ||||||
| 	MediaUpdate(ctx context.Context, authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) | 	MediaUpdate(ctx context.Context, authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, gtserror.WithCode) | ||||||
| 
 | 
 | ||||||
| 	// NotificationsGet | 	// NotificationsGet | ||||||
| 	NotificationsGet(ctx context.Context, authed *oauth.Auth, excludeTypes []string, limit int, maxID string, sinceID string) (*apimodel.TimelineResponse, gtserror.WithCode) | 	NotificationsGet(ctx context.Context, authed *oauth.Auth, excludeTypes []string, limit int, maxID string, sinceID string) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 	// NotificationsClear | 	// NotificationsClear | ||||||
| 	NotificationsClear(ctx context.Context, authed *oauth.Auth) gtserror.WithCode | 	NotificationsClear(ctx context.Context, authed *oauth.Auth) gtserror.WithCode | ||||||
| 
 | 
 | ||||||
|  | @ -192,11 +192,11 @@ type Processor interface { | ||||||
| 	StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) | 	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 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.TimelineResponse, gtserror.WithCode) | 	HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 	// PublicTimelineGet returns statuses from the public/local timeline, with the given filters/parameters. | 	// 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.TimelineResponse, gtserror.WithCode) | 	PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 	// FavedTimelineGet returns faved statuses, with the given filters/parameters. | 	// FavedTimelineGet returns faved statuses, with the given filters/parameters. | ||||||
| 	FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) | 	FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) | ||||||
| 
 | 
 | ||||||
| 	// AuthorizeStreamingRequest returns a gotosocial account in exchange for an access token, or an error if the given token is not valid. | 	// 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, gtserror.WithCode) | 	AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, gtserror.WithCode) | ||||||
|  |  | ||||||
|  | @ -137,40 +137,47 @@ func StatusSkipInsertFunction() timeline.SkipInsertFunction { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) | 	preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if len(preparedItems) == 0 { | 	count := len(preparedItems) | ||||||
| 		return util.EmptyTimelineResponse(), nil | 
 | ||||||
|  | 	if count == 0 { | ||||||
|  | 		return util.EmptyPageableResponse(), nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	items := []interface{}{} | ||||||
| 	for _, i := range preparedItems { | 	nextMaxIDValue := "" | ||||||
| 		status, ok := i.(*apimodel.Status) | 	prevMinIDValue := "" | ||||||
| 		if !ok { | 	for i, item := range preparedItems { | ||||||
| 			return nil, gtserror.NewErrorInternalError(errors.New("error converting prepared timeline entry to api status")) | 		if i == count-1 { | ||||||
|  | 			nextMaxIDValue = item.GetID() | ||||||
| 		} | 		} | ||||||
| 		timelineables = append(timelineables, status) | 
 | ||||||
|  | 		if i == 0 { | ||||||
|  | 			prevMinIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:          timelineables, | 		Items:          items, | ||||||
| 		Path:           "api/v1/timelines/home", | 		Path:           "api/v1/timelines/home", | ||||||
| 		NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), | 		NextMaxIDValue: nextMaxIDValue, | ||||||
| 		PrevMinIDValue: timelineables[0].GetID(), | 		PrevMinIDValue: prevMinIDValue, | ||||||
| 		Limit:          limit, | 		Limit:          limit, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	statuses, err := p.db.GetPublicTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) | 	statuses, err := p.db.GetPublicTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == db.ErrNoEntries { | 		if err == db.ErrNoEntries { | ||||||
| 			// there are just no entries left | 			// there are just no entries left | ||||||
| 			return util.EmptyTimelineResponse(), nil | 			return util.EmptyPageableResponse(), nil | ||||||
| 		} | 		} | ||||||
| 		// there's an actual error | 		// there's an actual error | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -181,30 +188,41 @@ func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, m | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if len(filtered) == 0 { | 	count := len(filtered) | ||||||
| 		return util.EmptyTimelineResponse(), nil | 
 | ||||||
|  | 	if count == 0 { | ||||||
|  | 		return util.EmptyPageableResponse(), nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	items := []interface{}{} | ||||||
| 	for _, i := range filtered { | 	nextMaxIDValue := "" | ||||||
| 		timelineables = append(timelineables, i) | 	prevMinIDValue := "" | ||||||
|  | 	for i, item := range filtered { | ||||||
|  | 		if i == count-1 { | ||||||
|  | 			nextMaxIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if i == 0 { | ||||||
|  | 			prevMinIDValue = item.GetID() | ||||||
|  | 		} | ||||||
|  | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:          timelineables, | 		Items:          items, | ||||||
| 		Path:           "api/v1/timelines/public", | 		Path:           "api/v1/timelines/public", | ||||||
| 		NextMaxIDValue: timelineables[len(timelineables)-1].GetID(), | 		NextMaxIDValue: nextMaxIDValue, | ||||||
| 		PrevMinIDValue: timelineables[0].GetID(), | 		PrevMinIDValue: prevMinIDValue, | ||||||
| 		Limit:          limit, | 		Limit:          limit, | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) { | func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
| 	statuses, nextMaxID, prevMinID, err := p.db.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit) | 	statuses, nextMaxID, prevMinID, err := p.db.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == db.ErrNoEntries { | 		if err == db.ErrNoEntries { | ||||||
| 			// there are just no entries left | 			// there are just no entries left | ||||||
| 			return util.EmptyTimelineResponse(), nil | 			return util.EmptyPageableResponse(), nil | ||||||
| 		} | 		} | ||||||
| 		// there's an actual error | 		// there's an actual error | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
|  | @ -216,16 +234,16 @@ func (p *processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if len(filtered) == 0 { | 	if len(filtered) == 0 { | ||||||
| 		return util.EmptyTimelineResponse(), nil | 		return util.EmptyPageableResponse(), nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	timelineables := []timeline.Timelineable{} | 	items := []interface{}{} | ||||||
| 	for _, i := range filtered { | 	for _, item := range filtered { | ||||||
| 		timelineables = append(timelineables, i) | 		items = append(items, item) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return util.PackageTimelineableResponse(util.TimelineableResponseParams{ | 	return util.PackagePageableResponse(util.PageableResponseParams{ | ||||||
| 		Items:          timelineables, | 		Items:          items, | ||||||
| 		Path:           "api/v1/favourites", | 		Path:           "api/v1/favourites", | ||||||
| 		NextMaxIDValue: nextMaxID, | 		NextMaxIDValue: nextMaxID, | ||||||
| 		PrevMinIDValue: prevMinID, | 		PrevMinIDValue: prevMinID, | ||||||
|  |  | ||||||
							
								
								
									
										119
									
								
								internal/util/paging.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								internal/util/paging.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,119 @@ | ||||||
|  | /* | ||||||
|  |    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" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // PageableResponseParams models the parameters to pass to PackagePageableResponse. | ||||||
|  | // | ||||||
|  | // The given items will be provided in the paged 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 PageableResponseParams struct { | ||||||
|  | 	Items            []interface{} // Sorted slice of items (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' | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // PackagePageableResponse is a convenience function for returning | ||||||
|  | // a bunch of pageable items (notifications, statuses, etc), as well | ||||||
|  | // as a Link header to inform callers of where to find next/prev items. | ||||||
|  | func PackagePageableResponse(params PageableResponseParams) (*apimodel.PageableResponse, gtserror.WithCode) { | ||||||
|  | 	if params.NextMaxIDKey == "" { | ||||||
|  | 		params.NextMaxIDKey = "max_id" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if params.PrevMinIDKey == "" { | ||||||
|  | 		params.PrevMinIDKey = "min_id" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	pageableResponse := EmptyPageableResponse() | ||||||
|  | 
 | ||||||
|  | 	if len(params.Items) == 0 { | ||||||
|  | 		return pageableResponse, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// items | ||||||
|  | 	pageableResponse.Items = params.Items | ||||||
|  | 
 | ||||||
|  | 	protocol := config.GetProtocol() | ||||||
|  | 	host := config.GetHost() | ||||||
|  | 
 | ||||||
|  | 	// next | ||||||
|  | 	nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue | ||||||
|  | 	if params.Limit != 0 { | ||||||
|  | 		nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw | ||||||
|  | 	} | ||||||
|  | 	for _, p := range params.ExtraQueryParams { | ||||||
|  | 		nextRaw = nextRaw + "&" + p | ||||||
|  | 	} | ||||||
|  | 	nextLink := &url.URL{ | ||||||
|  | 		Scheme:   protocol, | ||||||
|  | 		Host:     host, | ||||||
|  | 		Path:     params.Path, | ||||||
|  | 		RawQuery: nextRaw, | ||||||
|  | 	} | ||||||
|  | 	nextLinkString := nextLink.String() | ||||||
|  | 	pageableResponse.NextLink = nextLinkString | ||||||
|  | 
 | ||||||
|  | 	// prev | ||||||
|  | 	prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue | ||||||
|  | 	if params.Limit != 0 { | ||||||
|  | 		prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw | ||||||
|  | 	} | ||||||
|  | 	for _, p := range params.ExtraQueryParams { | ||||||
|  | 		prevRaw = prevRaw + "&" + p | ||||||
|  | 	} | ||||||
|  | 	prevLink := &url.URL{ | ||||||
|  | 		Scheme:   protocol, | ||||||
|  | 		Host:     host, | ||||||
|  | 		Path:     params.Path, | ||||||
|  | 		RawQuery: prevRaw, | ||||||
|  | 	} | ||||||
|  | 	prevLinkString := prevLink.String() | ||||||
|  | 	pageableResponse.PrevLink = prevLinkString | ||||||
|  | 
 | ||||||
|  | 	// link header | ||||||
|  | 	next := fmt.Sprintf("<%s>; rel=\"next\"", nextLinkString) | ||||||
|  | 	prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLinkString) | ||||||
|  | 	pageableResponse.LinkHeader = next + ", " + prev | ||||||
|  | 
 | ||||||
|  | 	return pageableResponse, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // EmptyPageableResponse just returns an empty | ||||||
|  | // PageableResponse with no link header or items. | ||||||
|  | func EmptyPageableResponse() *apimodel.PageableResponse { | ||||||
|  | 	return &apimodel.PageableResponse{ | ||||||
|  | 		Items: []interface{}{}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -1,117 +0,0 @@ | ||||||
| /* |  | ||||||
|    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, |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if len(params.Items) != 0 { |  | ||||||
| 		protocol := config.GetProtocol() |  | ||||||
| 		host := config.GetHost() |  | ||||||
| 
 |  | ||||||
| 		// next |  | ||||||
| 		nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue |  | ||||||
| 		if params.Limit != 0 { |  | ||||||
| 			nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw |  | ||||||
| 		} |  | ||||||
| 		for _, p := range params.ExtraQueryParams { |  | ||||||
| 			nextRaw = nextRaw + "&" + p |  | ||||||
| 		} |  | ||||||
| 		nextLink := &url.URL{ |  | ||||||
| 			Scheme:   protocol, |  | ||||||
| 			Host:     host, |  | ||||||
| 			Path:     params.Path, |  | ||||||
| 			RawQuery: nextRaw, |  | ||||||
| 		} |  | ||||||
| 		nextLinkString := nextLink.String() |  | ||||||
| 		timelineResponse.NextLink = nextLinkString |  | ||||||
| 
 |  | ||||||
| 		// prev |  | ||||||
| 		prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue |  | ||||||
| 		if params.Limit != 0 { |  | ||||||
| 			prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw |  | ||||||
| 		} |  | ||||||
| 		for _, p := range params.ExtraQueryParams { |  | ||||||
| 			prevRaw = prevRaw + "&" + p |  | ||||||
| 		} |  | ||||||
| 		prevLink := &url.URL{ |  | ||||||
| 			Scheme:   protocol, |  | ||||||
| 			Host:     host, |  | ||||||
| 			Path:     params.Path, |  | ||||||
| 			RawQuery: prevRaw, |  | ||||||
| 		} |  | ||||||
| 		prevLinkString := prevLink.String() |  | ||||||
| 		timelineResponse.PrevLink = prevLinkString |  | ||||||
| 
 |  | ||||||
| 		// link header |  | ||||||
| 		next := fmt.Sprintf("<%s>; rel=\"next\"", nextLinkString) |  | ||||||
| 		prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLinkString) |  | ||||||
| 		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{}, |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue