forked from mirrors/gotosocial
[feature] Add back/next buttons to profiles for paging through statuses (#708)
* add GetAccountWebStatuses to db * add WebStatusesGet func to processor * don't add limit to next/prev links if 0 * take query params for next/prev statuses * add separate next + prev links for convenience * show 'nothing here' message if no statuses exist * add back / next links to profiles * allow paging down only * go fmt ./... * 'recent public toots' -> 'latest public toots'
This commit is contained in:
parent
6934ae378a
commit
6418307c64
11 changed files with 183 additions and 36 deletions
|
@ -25,4 +25,6 @@ import "github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||||
type TimelineResponse struct {
|
type TimelineResponse struct {
|
||||||
Items []timeline.Timelineable
|
Items []timeline.Timelineable
|
||||||
LinkHeader string
|
LinkHeader string
|
||||||
|
NextLink string
|
||||||
|
PrevLink string
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,11 @@ type Account interface {
|
||||||
// In case of no entries, a 'no entries' error will be returned
|
// In case of no entries, a 'no entries' error will be returned
|
||||||
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error)
|
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error)
|
||||||
|
|
||||||
|
// GetAccountWebStatuses is similar to GetAccountStatuses, but it's specifically for returning statuses that
|
||||||
|
// should be visible via the web view of an account. So, only public, federated statuses that aren't boosts
|
||||||
|
// or replies.
|
||||||
|
GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error)
|
||||||
|
|
||||||
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
|
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
|
||||||
|
|
||||||
// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
|
// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
|
||||||
|
|
|
@ -301,27 +301,33 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
|
||||||
return nil, a.conn.ProcessError(err)
|
return nil, a.conn.ProcessError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch case of no statuses early
|
return a.statusesFromIDs(ctx, statusIDs)
|
||||||
if len(statusIDs) == 0 {
|
}
|
||||||
return nil, db.ErrNoEntries
|
|
||||||
|
func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, db.Error) {
|
||||||
|
statusIDs := []string{}
|
||||||
|
|
||||||
|
q := a.conn.
|
||||||
|
NewSelect().
|
||||||
|
Table("statuses").
|
||||||
|
Column("id").
|
||||||
|
Where("account_id = ?", accountID).
|
||||||
|
WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id")).
|
||||||
|
WhereGroup(" AND ", whereEmptyOrNull("boost_of_id")).
|
||||||
|
Where("visibility = ?", gtsmodel.VisibilityPublic).
|
||||||
|
Where("federated = ?", true)
|
||||||
|
|
||||||
|
if maxID != "" {
|
||||||
|
q = q.Where("id < ?", maxID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate return slice (will be at most len statusIDS)
|
q = q.Limit(limit).Order("id DESC")
|
||||||
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
|
|
||||||
|
|
||||||
for _, id := range statusIDs {
|
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||||
// Fetch from status from database by ID
|
return nil, a.conn.ProcessError(err)
|
||||||
status, err := a.status.GetStatusByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("GetAccountStatuses: error getting status %q: %v", id, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append to return slice
|
|
||||||
statuses = append(statuses, status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses, nil
|
return a.statusesFromIDs(ctx, statusIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) {
|
func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) {
|
||||||
|
@ -363,3 +369,27 @@ func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxI
|
||||||
prevMinID := blocks[0].ID
|
prevMinID := blocks[0].ID
|
||||||
return accounts, nextMaxID, prevMinID, nil
|
return accounts, nextMaxID, prevMinID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *accountDB) statusesFromIDs(ctx context.Context, statusIDs []string) ([]*gtsmodel.Status, db.Error) {
|
||||||
|
// Catch case of no statuses early
|
||||||
|
if len(statusIDs) == 0 {
|
||||||
|
return nil, db.ErrNoEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate return slice (will be at most len statusIDS)
|
||||||
|
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
|
||||||
|
|
||||||
|
for _, id := range statusIDs {
|
||||||
|
// Fetch from status from database by ID
|
||||||
|
status, err := a.status.GetStatusByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("statusesFromIDs: error getting status %q: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to return slice
|
||||||
|
statuses = append(statuses, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
|
@ -50,6 +50,10 @@ func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth,
|
||||||
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) {
|
||||||
|
return p.accountProcessor.WebStatusesGet(ctx, targetAccountID, maxID)
|
||||||
|
}
|
||||||
|
|
||||||
func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) {
|
func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) {
|
||||||
return p.accountProcessor.FollowersGet(ctx, authed.Account, targetAccountID)
|
return p.accountProcessor.FollowersGet(ctx, authed.Account, targetAccountID)
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,9 @@ type Processor interface {
|
||||||
// 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.TimelineResponse, gtserror.WithCode)
|
||||||
|
// 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.
|
||||||
|
WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, 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.
|
||||||
|
|
|
@ -84,3 +84,45 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *processor) WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, gtserror.WithCode) {
|
||||||
|
acct, err := p.db.GetAccountByID(ctx, targetAccountID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrNoEntries {
|
||||||
|
err := fmt.Errorf("account %s not found in the db, not getting web statuses for it", targetAccountID)
|
||||||
|
return nil, gtserror.NewErrorNotFound(err)
|
||||||
|
}
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if acct.Domain != "" {
|
||||||
|
err := fmt.Errorf("account %s was not a local account, not getting web statuses for it", targetAccountID)
|
||||||
|
return nil, gtserror.NewErrorNotFound(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := p.db.GetAccountWebStatuses(ctx, targetAccountID, 10, maxID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrNoEntries {
|
||||||
|
return util.EmptyTimelineResponse(), nil
|
||||||
|
}
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timelineables := []timeline.Timelineable{}
|
||||||
|
for _, i := range statuses {
|
||||||
|
apiStatus, err := p.tc.StatusToAPIStatus(ctx, i, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
timelineables = append(timelineables, apiStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
return util.PackageTimelineableResponse(util.TimelineableResponseParams{
|
||||||
|
Items: timelineables,
|
||||||
|
Path: "/@" + acct.Username,
|
||||||
|
NextMaxIDValue: timelineables[len(timelineables)-1].GetID(),
|
||||||
|
PrevMinIDValue: timelineables[0].GetID(),
|
||||||
|
ExtraQueryParams: []string{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -84,6 +84,9 @@ type Processor interface {
|
||||||
// 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.TimelineResponse, gtserror.WithCode)
|
||||||
|
// 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.
|
||||||
|
AccountWebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.TimelineResponse, 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.
|
||||||
|
|
|
@ -61,12 +61,15 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
|
||||||
Items: params.Items,
|
Items: params.Items,
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepare the next and previous links
|
|
||||||
if len(params.Items) != 0 {
|
if len(params.Items) != 0 {
|
||||||
protocol := config.GetProtocol()
|
protocol := config.GetProtocol()
|
||||||
host := config.GetHost()
|
host := config.GetHost()
|
||||||
|
|
||||||
nextRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.NextMaxIDKey, params.NextMaxIDValue)
|
// next
|
||||||
|
nextRaw := params.NextMaxIDKey + "=" + params.NextMaxIDValue
|
||||||
|
if params.Limit != 0 {
|
||||||
|
nextRaw = fmt.Sprintf("limit=%d&", params.Limit) + nextRaw
|
||||||
|
}
|
||||||
for _, p := range params.ExtraQueryParams {
|
for _, p := range params.ExtraQueryParams {
|
||||||
nextRaw = nextRaw + "&" + p
|
nextRaw = nextRaw + "&" + p
|
||||||
}
|
}
|
||||||
|
@ -76,9 +79,14 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
|
||||||
Path: params.Path,
|
Path: params.Path,
|
||||||
RawQuery: nextRaw,
|
RawQuery: nextRaw,
|
||||||
}
|
}
|
||||||
next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String())
|
nextLinkString := nextLink.String()
|
||||||
|
timelineResponse.NextLink = nextLinkString
|
||||||
|
|
||||||
prevRaw := fmt.Sprintf("limit=%d&%s=%s", params.Limit, params.PrevMinIDKey, params.PrevMinIDValue)
|
// prev
|
||||||
|
prevRaw := params.PrevMinIDKey + "=" + params.PrevMinIDValue
|
||||||
|
if params.Limit != 0 {
|
||||||
|
prevRaw = fmt.Sprintf("limit=%d&", params.Limit) + prevRaw
|
||||||
|
}
|
||||||
for _, p := range params.ExtraQueryParams {
|
for _, p := range params.ExtraQueryParams {
|
||||||
prevRaw = prevRaw + "&" + p
|
prevRaw = prevRaw + "&" + p
|
||||||
}
|
}
|
||||||
|
@ -88,7 +96,12 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
|
||||||
Path: params.Path,
|
Path: params.Path,
|
||||||
RawQuery: prevRaw,
|
RawQuery: prevRaw,
|
||||||
}
|
}
|
||||||
prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String())
|
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
|
timelineResponse.LinkHeader = next + ", " + prev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,11 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxStatusIDKey is for specifying the maximum ID of the status to retrieve.
|
||||||
|
MaxStatusIDKey = "max_id"
|
||||||
|
)
|
||||||
|
|
||||||
func (m *Module) profileGETHandler(c *gin.Context) {
|
func (m *Module) profileGETHandler(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
@ -78,10 +83,18 @@ func (m *Module) profileGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// get latest 10 top-level public statuses;
|
// we should only show the 'back to top' button if the
|
||||||
// ie., exclude replies and boosts, public only,
|
// profile visitor is paging through statuses
|
||||||
// with or without media
|
showBackToTop := false
|
||||||
statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true)
|
|
||||||
|
maxStatusID := ""
|
||||||
|
maxStatusIDString := c.Query(MaxStatusIDKey)
|
||||||
|
if maxStatusIDString != "" {
|
||||||
|
maxStatusID = maxStatusIDString
|
||||||
|
showBackToTop = true
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, errWithCode := m.processor.AccountWebStatusesGet(ctx, account.ID, maxStatusID)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
api.ErrorHandler(c, errWithCode, instanceGet)
|
api.ErrorHandler(c, errWithCode, instanceGet)
|
||||||
return
|
return
|
||||||
|
@ -103,9 +116,11 @@ func (m *Module) profileGETHandler(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "profile.tmpl", gin.H{
|
c.HTML(http.StatusOK, "profile.tmpl", gin.H{
|
||||||
"instance": instance,
|
"instance": instance,
|
||||||
"account": account,
|
"account": account,
|
||||||
"statuses": statusResp.Items,
|
"statuses": statusResp.Items,
|
||||||
|
"statuses_next": statusResp.NextLink,
|
||||||
|
"show_back_to_top": showBackToTop,
|
||||||
"stylesheets": []string{
|
"stylesheets": []string{
|
||||||
"/assets/Fork-Awesome/css/fork-awesome.min.css",
|
"/assets/Fork-Awesome/css/fork-awesome.min.css",
|
||||||
"/assets/dist/status.css",
|
"/assets/dist/status.css",
|
||||||
|
|
|
@ -160,6 +160,24 @@ main {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nothinghere {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backnextlinks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toot, .toot:last-child {
|
.toot, .toot:last-child {
|
||||||
box-shadow: $boxshadow;
|
box-shadow: $boxshadow;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,13 +27,25 @@
|
||||||
<div class="entry">Posted <b>{{.account.StatusesCount}}</b></div>
|
<div class="entry">Posted <b>{{.account.StatusesCount}}</b></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="recent">Recent public toots</h2>
|
<h2 id="recent">Latest public toots</h2>
|
||||||
<div class="thread">
|
{{ if not .statuses }}
|
||||||
{{range .statuses}}
|
<div class="nothinghere">Nothing here!</div>
|
||||||
<div class="toot expanded">
|
{{ else }}
|
||||||
{{ template "status.tmpl" .}}
|
<div class="thread">
|
||||||
</div>
|
{{ range .statuses }}
|
||||||
{{end}}
|
<div class="toot expanded">
|
||||||
</div>
|
{{ template "status.tmpl" .}}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="backnextlinks">
|
||||||
|
{{ if .show_back_to_top }}
|
||||||
|
<a href="/@{{ .account.Username }}">Back to top</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .statuses_next }}
|
||||||
|
<a href="{{ .statuses_next }}" class="next">Show older</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{{ template "footer.tmpl" .}}
|
{{ template "footer.tmpl" .}}
|
||||||
|
|
Loading…
Reference in a new issue