Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Add back/next buttons to profiles for paging through statuses #708

Merged
merged 10 commits into from
Jul 13, 2022
2 changes: 2 additions & 0 deletions internal/api/model/timeline.go
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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" .}}