From f4920c9c7f5947d3b6476610f39bc3492ab4ef3b Mon Sep 17 00:00:00 2001
From: Brecht Van Lommel <brecht@blender.org>
Date: Fri, 24 Feb 2023 22:15:10 +0100
Subject: [PATCH] Add pagination for dashboard and user activity feeds (#22937)

Previously only the last few activities where available. This works for
all activity and for activity on a date chosen on the heatmap.
---
 models/activities/action.go               | 16 +++++------
 models/activities/action_test.go          | 27 ++++++++++++------
 models/activities/user_heatmap_test.go    |  3 +-
 routers/web/feed/profile.go               |  2 +-
 routers/web/feed/repo.go                  |  2 +-
 routers/web/user/home.go                  | 25 +++++++++++++++--
 routers/web/user/profile.go               | 34 +++++++++++++++++------
 templates/user/dashboard/feeds.tmpl       |  2 ++
 web_src/js/components/ActivityHeatmap.vue |  2 ++
 9 files changed, 81 insertions(+), 32 deletions(-)

diff --git a/models/activities/action.go b/models/activities/action.go
index 2e845bf89e..1412d2c051 100644
--- a/models/activities/action.go
+++ b/models/activities/action.go
@@ -380,14 +380,14 @@ type GetFeedsOptions struct {
 }
 
 // GetFeeds returns actions according to the provided options
-func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, error) {
+func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, int64, error) {
 	if opts.RequestedUser == nil && opts.RequestedTeam == nil && opts.RequestedRepo == nil {
-		return nil, fmt.Errorf("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo")
+		return nil, 0, fmt.Errorf("need at least one of these filters: RequestedUser, RequestedTeam, RequestedRepo")
 	}
 
 	cond, err := activityQueryCondition(opts)
 	if err != nil {
-		return nil, err
+		return nil, 0, err
 	}
 
 	sess := db.GetEngine(ctx).Where(cond).
@@ -398,16 +398,16 @@ func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, error) {
 	sess = db.SetSessionPagination(sess, &opts)
 
 	actions := make([]*Action, 0, opts.PageSize)
-
-	if err := sess.Desc("`action`.created_unix").Find(&actions); err != nil {
-		return nil, fmt.Errorf("Find: %w", err)
+	count, err := sess.Desc("`action`.created_unix").FindAndCount(&actions)
+	if err != nil {
+		return nil, 0, fmt.Errorf("FindAndCount: %w", err)
 	}
 
 	if err := ActionList(actions).loadAttributes(ctx); err != nil {
-		return nil, fmt.Errorf("LoadAttributes: %w", err)
+		return nil, 0, fmt.Errorf("LoadAttributes: %w", err)
 	}
 
-	return actions, nil
+	return actions, count, nil
 }
 
 // ActivityReadable return whether doer can read activities of user
diff --git a/models/activities/action_test.go b/models/activities/action_test.go
index f37e58f685..2fd86bb8f6 100644
--- a/models/activities/action_test.go
+++ b/models/activities/action_test.go
@@ -44,7 +44,7 @@ func TestGetFeeds(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 
-	actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedUser:   user,
 		Actor:           user,
 		IncludePrivate:  true,
@@ -56,8 +56,9 @@ func TestGetFeeds(t *testing.T) {
 		assert.EqualValues(t, 1, actions[0].ID)
 		assert.EqualValues(t, user.ID, actions[0].UserID)
 	}
+	assert.Equal(t, int64(1), count)
 
-	actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedUser:   user,
 		Actor:           user,
 		IncludePrivate:  false,
@@ -65,6 +66,7 @@ func TestGetFeeds(t *testing.T) {
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 0)
+	assert.Equal(t, int64(0), count)
 }
 
 func TestGetFeedsForRepos(t *testing.T) {
@@ -74,38 +76,42 @@ func TestGetFeedsForRepos(t *testing.T) {
 	pubRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 8})
 
 	// private repo & no login
-	actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedRepo:  privRepo,
 		IncludePrivate: true,
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 0)
+	assert.Equal(t, int64(0), count)
 
 	// public repo & no login
-	actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedRepo:  pubRepo,
 		IncludePrivate: true,
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 1)
+	assert.Equal(t, int64(1), count)
 
 	// private repo and login
-	actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedRepo:  privRepo,
 		IncludePrivate: true,
 		Actor:          user,
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 1)
+	assert.Equal(t, int64(1), count)
 
 	// public repo & login
-	actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedRepo:  pubRepo,
 		IncludePrivate: true,
 		Actor:          user,
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 1)
+	assert.Equal(t, int64(1), count)
 }
 
 func TestGetFeeds2(t *testing.T) {
@@ -114,7 +120,7 @@ func TestGetFeeds2(t *testing.T) {
 	org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
 	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
 
-	actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedUser:   org,
 		Actor:           user,
 		IncludePrivate:  true,
@@ -127,8 +133,9 @@ func TestGetFeeds2(t *testing.T) {
 		assert.EqualValues(t, 2, actions[0].ID)
 		assert.EqualValues(t, org.ID, actions[0].UserID)
 	}
+	assert.Equal(t, int64(1), count)
 
-	actions, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err = activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedUser:   org,
 		Actor:           user,
 		IncludePrivate:  false,
@@ -137,6 +144,7 @@ func TestGetFeeds2(t *testing.T) {
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 0)
+	assert.Equal(t, int64(0), count)
 }
 
 func TestActivityReadable(t *testing.T) {
@@ -224,13 +232,14 @@ func TestGetFeedsCorrupted(t *testing.T) {
 		RepoID: 1700,
 	})
 
-	actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+	actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 		RequestedUser:  user,
 		Actor:          user,
 		IncludePrivate: true,
 	})
 	assert.NoError(t, err)
 	assert.Len(t, actions, 0)
+	assert.Equal(t, int64(0), count)
 }
 
 func TestConsistencyUpdateAction(t *testing.T) {
diff --git a/models/activities/user_heatmap_test.go b/models/activities/user_heatmap_test.go
index 34715ab6dd..98df7b38aa 100644
--- a/models/activities/user_heatmap_test.go
+++ b/models/activities/user_heatmap_test.go
@@ -73,7 +73,7 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
 		}
 
 		// get the action for comparison
-		actions, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
+		actions, count, err := activities_model.GetFeeds(db.DefaultContext, activities_model.GetFeedsOptions{
 			RequestedUser:   user,
 			Actor:           doer,
 			IncludePrivate:  true,
@@ -90,6 +90,7 @@ func TestGetUserHeatmapDataByUser(t *testing.T) {
 		}
 		assert.NoError(t, err)
 		assert.Len(t, actions, contributions, "invalid action count: did the test data became too old?")
+		assert.Equal(t, count, int64(contributions))
 		assert.Equal(t, tc.CountResult, contributions, fmt.Sprintf("testcase '%s'", tc.desc))
 
 		// Test JSON rendering
diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go
index 7641769192..b9dda2fc10 100644
--- a/routers/web/feed/profile.go
+++ b/routers/web/feed/profile.go
@@ -26,7 +26,7 @@ func ShowUserFeedAtom(ctx *context.Context) {
 func showUserFeed(ctx *context.Context, formatType string) {
 	includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
 
-	actions, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+	actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
 		RequestedUser:   ctx.ContextUser,
 		Actor:           ctx.Doer,
 		IncludePrivate:  includePrivate,
diff --git a/routers/web/feed/repo.go b/routers/web/feed/repo.go
index 1d24b58800..5fcad26779 100644
--- a/routers/web/feed/repo.go
+++ b/routers/web/feed/repo.go
@@ -15,7 +15,7 @@ import (
 
 // ShowRepoFeed shows user activity on the repo as RSS / Atom feed
 func ShowRepoFeed(ctx *context.Context, repo *repo_model.Repository, formatType string) {
-	actions, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+	actions, _, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
 		RequestedRepo:  repo,
 		Actor:          ctx.Doer,
 		IncludePrivate: true,
diff --git a/routers/web/user/home.go b/routers/web/user/home.go
index 4f45c1d5c3..2593ab148c 100644
--- a/routers/web/user/home.go
+++ b/routers/web/user/home.go
@@ -72,12 +72,23 @@ func Dashboard(ctx *context.Context) {
 		return
 	}
 
+	var (
+		date = ctx.FormString("date")
+		page = ctx.FormInt("page")
+	)
+
+	// Make sure page number is at least 1. Will be posted to ctx.Data.
+	if page <= 1 {
+		page = 1
+	}
+
 	ctx.Data["Title"] = ctxUser.DisplayName() + " - " + ctx.Tr("dashboard")
 	ctx.Data["PageIsDashboard"] = true
 	ctx.Data["PageIsNews"] = true
 	cnt, _ := organization.GetOrganizationCount(ctx, ctxUser)
 	ctx.Data["UserOrgsCount"] = cnt
 	ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled
+	ctx.Data["Date"] = date
 
 	var uid int64
 	if ctxUser != nil {
@@ -98,8 +109,7 @@ func Dashboard(ctx *context.Context) {
 		ctx.Data["HeatmapData"] = data
 	}
 
-	var err error
-	ctx.Data["Feeds"], err = activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+	feeds, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
 		RequestedUser:   ctxUser,
 		RequestedTeam:   ctx.Org.Team,
 		Actor:           ctx.Doer,
@@ -107,13 +117,22 @@ func Dashboard(ctx *context.Context) {
 		OnlyPerformedBy: false,
 		IncludeDeleted:  false,
 		Date:            ctx.FormString("date"),
-		ListOptions:     db.ListOptions{PageSize: setting.UI.FeedPagingNum},
+		ListOptions: db.ListOptions{
+			Page:     page,
+			PageSize: setting.UI.FeedPagingNum,
+		},
 	})
 	if err != nil {
 		ctx.ServerError("GetFeeds", err)
 		return
 	}
 
+	ctx.Data["Feeds"] = feeds
+
+	pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5)
+	pager.AddParam(ctx, "date", "Date")
+	ctx.Data["Page"] = pager
+
 	ctx.HTML(http.StatusOK, tplDashboard)
 }
 
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 4f0a816569..b445260459 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -119,6 +119,11 @@ func Profile(ctx *context.Context) {
 		page = 1
 	}
 
+	pagingNum := setting.UI.User.RepoPagingNum
+	if tab == "activity" {
+		pagingNum = setting.UI.FeedPagingNum
+	}
+
 	topicOnly := ctx.FormBool("topic")
 
 	var (
@@ -164,7 +169,7 @@ func Profile(ctx *context.Context) {
 	switch tab {
 	case "followers":
 		items, count, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{
-			PageSize: setting.UI.User.RepoPagingNum,
+			PageSize: pagingNum,
 			Page:     page,
 		})
 		if err != nil {
@@ -176,7 +181,7 @@ func Profile(ctx *context.Context) {
 		total = int(count)
 	case "following":
 		items, count, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{
-			PageSize: setting.UI.User.RepoPagingNum,
+			PageSize: pagingNum,
 			Page:     page,
 		})
 		if err != nil {
@@ -187,24 +192,32 @@ func Profile(ctx *context.Context) {
 
 		total = int(count)
 	case "activity":
-		ctx.Data["Feeds"], err = activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
+		date := ctx.FormString("date")
+		items, count, err := activities_model.GetFeeds(ctx, activities_model.GetFeedsOptions{
 			RequestedUser:   ctx.ContextUser,
 			Actor:           ctx.Doer,
 			IncludePrivate:  showPrivate,
 			OnlyPerformedBy: true,
 			IncludeDeleted:  false,
-			Date:            ctx.FormString("date"),
-			ListOptions:     db.ListOptions{PageSize: setting.UI.FeedPagingNum},
+			Date:            date,
+			ListOptions: db.ListOptions{
+				PageSize: pagingNum,
+				Page:     page,
+			},
 		})
 		if err != nil {
 			ctx.ServerError("GetFeeds", err)
 			return
 		}
+		ctx.Data["Feeds"] = items
+		ctx.Data["Date"] = date
+
+		total = int(count)
 	case "stars":
 		ctx.Data["PageIsProfileStarList"] = true
 		repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
 			ListOptions: db.ListOptions{
-				PageSize: setting.UI.User.RepoPagingNum,
+				PageSize: pagingNum,
 				Page:     page,
 			},
 			Actor:              ctx.Doer,
@@ -236,7 +249,7 @@ func Profile(ctx *context.Context) {
 	case "watching":
 		repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
 			ListOptions: db.ListOptions{
-				PageSize: setting.UI.User.RepoPagingNum,
+				PageSize: pagingNum,
 				Page:     page,
 			},
 			Actor:              ctx.Doer,
@@ -258,7 +271,7 @@ func Profile(ctx *context.Context) {
 	default:
 		repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
 			ListOptions: db.ListOptions{
-				PageSize: setting.UI.User.RepoPagingNum,
+				PageSize: pagingNum,
 				Page:     page,
 			},
 			Actor:              ctx.Doer,
@@ -281,12 +294,15 @@ func Profile(ctx *context.Context) {
 	ctx.Data["Repos"] = repos
 	ctx.Data["Total"] = total
 
-	pager := context.NewPagination(total, setting.UI.User.RepoPagingNum, page, 5)
+	pager := context.NewPagination(total, pagingNum, page, 5)
 	pager.SetDefaultParams(ctx)
 	pager.AddParam(ctx, "tab", "TabName")
 	if tab != "followers" && tab != "following" && tab != "activity" && tab != "projects" {
 		pager.AddParam(ctx, "language", "Language")
 	}
+	if tab == "activity" {
+		pager.AddParam(ctx, "date", "Date")
+	}
 	ctx.Data["Page"] = pager
 	ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
 	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl
index 3f156249ff..75a76db570 100644
--- a/templates/user/dashboard/feeds.tmpl
+++ b/templates/user/dashboard/feeds.tmpl
@@ -124,3 +124,5 @@
 		<div class="ui divider"></div>
 	</div>
 {{end}}
+
+{{template "base/paginate" .}}
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
index d0f81c586c..98ffce44b5 100644
--- a/web_src/js/components/ActivityHeatmap.vue
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -70,6 +70,8 @@ export default {
         params.set('date', clickedDate);
       }
 
+      params.delete('page');
+
       const newSearch = params.toString();
       window.location.search = newSearch.length ? `?${newSearch}` : '';
     }