diff --git a/internal/api/model/timeline.go b/internal/api/model/timeline.go index 71d839ed..4c9c78a8 100644 --- 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 index 4b0b0062..79e7c01a 100644 --- 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 index cf02ad10..2c97a664 100644 --- 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) +} + +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 +} diff --git a/internal/processing/account.go b/internal/processing/account.go index 2c21aee2..df351d7b 100644 --- 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 index 56dfb90e..868308ef 100644 --- 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 index 90bf8e06..7dbe6846 100644 --- 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 index b8d0daf9..5a4abb55 100644 --- 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 index 929464ad..bdb0f3f0 100644 --- 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 index ce3fe645..542c015f 100644 --- 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 index ca4192e1..d0ab7e26 100644 --- 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 index d65b45d5..458f68f5 100644 --- 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" .}}