commit 6418307c64da236a0268f4496f793d982e128ad0
parent 6934ae378ab5743da80a5995fc74d167502187b1
Author: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Wed, 13 Jul 2022 09:57:47 +0200
[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'
Diffstat:
11 files changed, 183 insertions(+), 36 deletions(-)
diff --git a/internal/api/model/timeline.go b/internal/api/model/timeline.go
@@ -25,4 +25,6 @@ import "github.com/superseriousbusiness/gotosocial/internal/timeline"
type TimelineResponse struct {
Items []timeline.Timelineable
LinkHeader string
+ NextLink string
+ PrevLink string
}
diff --git a/internal/db/account.go b/internal/db/account.go
@@ -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.
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
@@ -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) {
@@ -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
+}
diff --git a/internal/processing/account.go b/internal/processing/account.go
@@ -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)
}
diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go
@@ -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.
diff --git a/internal/processing/account/getstatuses.go b/internal/processing/account/getstatuses.go
@@ -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{},
+ })
+}
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
@@ -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.
diff --git a/internal/util/timeline.go b/internal/util/timeline.go
@@ -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
}
@@ -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
}
@@ -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
}
diff --git a/internal/web/profile.go b/internal/web/profile.go
@@ -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()
@@ -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
@@ -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",
diff --git a/web/source/css/profile.css b/web/source/css/profile.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 {
box-shadow: $boxshadow;
}
diff --git a/web/template/profile.tmpl b/web/template/profile.tmpl
@@ -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" .}}