Skip to content

Commit

Permalink
[feature] Add back/next buttons to profiles for paging through status…
Browse files Browse the repository at this point in the history
…es (#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'
  • Loading branch information
tsmethurst committed Jul 13, 2022
1 parent 6934ae3 commit 6418307
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 36 deletions.
2 changes: 2 additions & 0 deletions internal/api/model/timeline.go
Expand Up @@ -25,4 +25,6 @@ import "github.com/superseriousbusiness/gotosocial/internal/timeline"
type TimelineResponse struct {
Items []timeline.Timelineable
LinkHeader string
NextLink string
PrevLink string
}
5 changes: 5 additions & 0 deletions internal/db/account.go
Expand Up @@ -54,6 +54,11 @@ type Account interface {
// 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)

// 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)

// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
Expand Down
62 changes: 46 additions & 16 deletions internal/db/bundb/account.go
Expand Up @@ -301,27 +301,33 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
return nil, a.conn.ProcessError(err)
}

// Catch case of no statuses early
if len(statusIDs) == 0 {
return nil, db.ErrNoEntries
}
return a.statusesFromIDs(ctx, statusIDs)
}

// Allocate return slice (will be at most len statusIDS)
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, db.Error) {
statusIDs := []string{}

for _, id := range statusIDs {
// Fetch from status from database by ID
status, err := a.status.GetStatusByID(ctx, id)
if err != nil {
logrus.Errorf("GetAccountStatuses: error getting status %q: %v", id, err)
continue
}
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)

// Append to return slice
statuses = append(statuses, status)
if maxID != "" {
q = q.Where("id < ?", maxID)
}

return statuses, nil
q = q.Limit(limit).Order("id DESC")

if err := q.Scan(ctx, &statusIDs); err != nil {
return nil, a.conn.ProcessError(err)
}

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) {
Expand Down Expand Up @@ -363,3 +369,27 @@ func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxI
prevMinID := blocks[0].ID
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
}
4 changes: 4 additions & 0 deletions internal/processing/account.go
Expand Up @@ -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)
}

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) {
return p.accountProcessor.FollowersGet(ctx, authed.Account, targetAccountID)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/processing/account/account.go
Expand Up @@ -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
// 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)
// 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(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// FollowingGet fetches a list of the accounts that target account is following.
Expand Down
42 changes: 42 additions & 0 deletions internal/processing/account/getstatuses.go
Expand Up @@ -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{},
})
}
3 changes: 3 additions & 0 deletions internal/processing/processor.go
Expand Up @@ -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
// 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)
// 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(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
// AccountFollowingGet fetches a list of the accounts that target account is following.
Expand Down
23 changes: 18 additions & 5 deletions internal/util/timeline.go
Expand Up @@ -61,12 +61,15 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
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)
// 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
}
Expand All @@ -76,9 +79,14 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
Path: params.Path,
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 {
prevRaw = prevRaw + "&" + p
}
Expand All @@ -88,7 +96,12 @@ func PackageTimelineableResponse(params TimelineableResponseParams) (*apimodel.T
Path: params.Path,
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
}

Expand Down
29 changes: 22 additions & 7 deletions internal/web/profile.go
Expand Up @@ -36,6 +36,11 @@ import (
"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) {
ctx := c.Request.Context()

Expand Down Expand Up @@ -78,10 +83,18 @@ func (m *Module) profileGETHandler(c *gin.Context) {
return
}

// get latest 10 top-level public statuses;
// ie., exclude replies and boosts, public only,
// with or without media
statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true)
// we should only show the 'back to top' button if the
// profile visitor is paging through statuses
showBackToTop := false

maxStatusID := ""
maxStatusIDString := c.Query(MaxStatusIDKey)
if maxStatusIDString != "" {
maxStatusID = maxStatusIDString
showBackToTop = true
}

statusResp, errWithCode := m.processor.AccountWebStatusesGet(ctx, account.ID, maxStatusID)
if errWithCode != nil {
api.ErrorHandler(c, errWithCode, instanceGet)
return
Expand All @@ -103,9 +116,11 @@ func (m *Module) profileGETHandler(c *gin.Context) {
}

c.HTML(http.StatusOK, "profile.tmpl", gin.H{
"instance": instance,
"account": account,
"statuses": statusResp.Items,
"instance": instance,
"account": account,
"statuses": statusResp.Items,
"statuses_next": statusResp.NextLink,
"show_back_to_top": showBackToTop,
"stylesheets": []string{
"/assets/Fork-Awesome/css/fork-awesome.min.css",
"/assets/dist/status.css",
Expand Down
18 changes: 18 additions & 0 deletions web/source/css/profile.css
Expand Up @@ -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 {
box-shadow: $boxshadow;
}
Expand Down
28 changes: 20 additions & 8 deletions web/template/profile.tmpl
Expand Up @@ -27,13 +27,25 @@
<div class="entry">Posted <b>{{.account.StatusesCount}}</b></div>
</div>
</div>
<h2 id="recent">Recent public toots</h2>
<div class="thread">
{{range .statuses}}
<div class="toot expanded">
{{ template "status.tmpl" .}}
</div>
{{end}}
</div>
<h2 id="recent">Latest public toots</h2>
{{ if not .statuses }}
<div class="nothinghere">Nothing here!</div>
{{ else }}
<div class="thread">
{{ range .statuses }}
<div class="toot expanded">
{{ 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>
{{ template "footer.tmpl" .}}

0 comments on commit 6418307

Please sign in to comment.