mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-24 16:10:30 +00:00
[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 {
|
||||
Items []timeline.Timelineable
|
||||
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
|
||||
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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
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)
|
||||
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
|
||||
q = q.Limit(limit).Order("id DESC")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Append to return slice
|
||||
statuses = append(statuses, status)
|
||||
if err := q.Scan(ctx, &statusIDs); err != nil {
|
||||
return nil, a.conn.ProcessError(err)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
// 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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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" .}}
|
||||
|
|
Loading…
Reference in a new issue