[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:
tobi 2022-07-13 09:57:47 +02:00 committed by GitHub
parent 6934ae378a
commit 6418307c64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 183 additions and 36 deletions

View file

@ -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
} }

View file

@ -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.

View file

@ -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
}

View file

@ -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)
} }

View file

@ -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.

View file

@ -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{},
})
}

View file

@ -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.

View file

@ -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
} }

View file

@ -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",

View file

@ -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;
} }

View file

@ -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" .}}