diff --git a/appview/db/timeline.go b/appview/db/timeline.go --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -11,7 +11,7 @@ // TODO: this gathers heterogenous events from different sources and aggregates // them in code; if we did this entirely in sql, we could order and limit and paginate easily -func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { +func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineGroup, error) { var events []models.TimelineEvent var userIsFollowing []string @@ -27,17 +27,22 @@ } } - repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) + // Fetch more events than we need to so that when we collapse each individual + // event into groups, we can still be relatively confident that we will have + // `limit` groups to fill the timeline with. Adjust multiplier as necessary. + fetchLimit := limit * 2 + + repos, err := getTimelineRepos(e, fetchLimit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } - stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) + stars, err := getTimelineStars(e, fetchLimit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } - follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) + follows, err := getTimelineFollows(e, fetchLimit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } @@ -50,12 +55,45 @@ return events[i].EventAt.After(events[j].EventAt) }) - // Limit the slice to 100 events - if len(events) > limit { - events = events[:limit] + groups := collapseTimeline(events) + if len(groups) > limit { + groups = groups[:limit] } + return groups, nil +} - return events, nil +// collapseTimeline merges consecutive events that share the same operation +// and target into one TimelineGroup (assumes events are sorted newest-first). +func collapseTimeline(events []models.TimelineEvent) []models.TimelineGroup { + var groups []models.TimelineGroup + i := 0 + for i < len(events) { + group := models.TimelineGroup{Primary: events[i]} + j := i + 1 + for j < len(events) && canCollapse(events[i], events[j]) { + group.Others = append(group.Others, events[j]) + j++ + } + groups = append(groups, group) + i = j + } + return groups +} + +// canCollapse reports whether two adjacent events in the timeline represent +// the same operation on the same target (repo starred or user followed). +func canCollapse(a, b models.TimelineEvent) bool { + switch { + case a.RepoStar != nil && b.RepoStar != nil: + if a.RepoStar.Repo == nil || b.RepoStar.Repo == nil { + return false + } + return a.RepoStar.Repo.RepoAt() == b.RepoStar.Repo.RepoAt() + case a.Follow != nil && b.Follow != nil: + return a.Follow.SubjectDid == b.Follow.SubjectDid + default: + return false + } } func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) { diff --git a/appview/models/timeline.go b/appview/models/timeline.go --- a/appview/models/timeline.go +++ b/appview/models/timeline.go @@ -21,3 +21,21 @@ IsStarred bool StarCount int64 } + +// TimelineGroup is a primary TimelineEvent plus zero or more peer events +// that share the same operation+target (same repo starred, same user +// followed) and arrived consecutively. Primary is the newest of the group; +// Others holds the older peers in descending order. For non-collapsible +// events (repo create) Others is always empty. +type TimelineGroup struct { + Primary TimelineEvent + Others []TimelineEvent +} + +func (g TimelineGroup) IsCollapsed() bool { + return len(g.Others) > 0 +} + +func (g TimelineGroup) OthersCount() int { + return len(g.Others) +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -403,7 +403,7 @@ type TimelineParams struct { LoggedInUser *oauth.MultiAccountUser - Timeline []models.TimelineEvent + Timeline []models.TimelineGroup Repos []models.Repo GfiLabel *models.LabelDefinition BlueskyPosts []models.BskyPost diff --git a/appview/pages/templates/timeline/fragments/timeline.html b/appview/pages/templates/timeline/fragments/timeline.html --- a/appview/pages/templates/timeline/fragments/timeline.html +++ b/appview/pages/templates/timeline/fragments/timeline.html @@ -6,22 +6,21 @@
- {{ range $i, $e := .Timeline }} + {{ range $i, $g := .Timeline }}
{{ if ne $i 0 }}
{{ end }} - {{ with $e }} -
- {{ if .Repo }} - {{ template "timeline/fragments/repoEvent" (list $ .) }} - {{ else if .RepoStar }} - {{ template "timeline/fragments/starEvent" (list $ .) }} - {{ else if .Follow }} - {{ template "timeline/fragments/followEvent" (list $ .) }} - {{ end }} -
- {{ end }} + {{ $primary := $g.Primary }} +
+ {{ if $primary.Repo }} + {{ template "timeline/fragments/repoEvent" (list $ $g) }} + {{ else if $primary.RepoStar }} + {{ template "timeline/fragments/starEvent" (list $ $g) }} + {{ else if $primary.Follow }} + {{ template "timeline/fragments/followEvent" (list $ $g) }} + {{ end }} +
{{ end }}
@@ -30,7 +29,8 @@ {{ define "timeline/fragments/repoEvent" }} {{ $root := index . 0 }} - {{ $event := index . 1 }} + {{ $group := index . 1 }} + {{ $event := $group.Primary }} {{ $repo := $event.Repo }} {{ $source := $event.Source }} {{ $userHandle := resolve $repo.Did }} @@ -59,14 +59,21 @@ {{ define "timeline/fragments/starEvent" }} {{ $root := index . 0 }} - {{ $event := index . 1 }} + {{ $group := index . 1 }} + {{ $event := $group.Primary }} {{ $star := $event.RepoStar }} {{ with $star }} - {{ $starrerHandle := resolve .Did }} {{ $repoOwnerHandle := resolve .Repo.Did }}
- {{ template "user/fragments/picHandleLink" $starrerHandle }} - starred + {{ template "user/fragments/picHandleLink" .Did }} + {{ if $group.IsCollapsed }} + + {{ template "timeline/fragments/othersBadge" $group }} + starred + + {{ else }} + starred + {{ end }} {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} @@ -80,21 +87,28 @@ {{ define "timeline/fragments/followEvent" }} {{ $root := index . 0 }} - {{ $event := index . 1 }} + {{ $group := index . 1 }} + {{ $event := $group.Primary }} {{ $follow := $event.Follow }} {{ $profile := $event.Profile }} {{ $followStats := $event.FollowStats }} {{ $followStatus := $event.FollowStatus }} - {{ $userHandle := resolve $follow.UserDid }} {{ $subjectHandle := resolve $follow.SubjectDid }}
- {{ template "user/fragments/picHandleLink" $userHandle }} - followed + {{ template "user/fragments/picHandleLink" $follow.UserDid }} + {{ if $group.IsCollapsed }} + + {{ template "timeline/fragments/othersBadge" $group }} + followed + + {{ else }} + followed + {{ end }} {{ template "user/fragments/picHandleLink" $subjectHandle }} {{ template "repo/fragments/time" $follow.FollowedAt }}
- {{ template "user/fragments/followCard" + {{ template "user/fragments/followCard" (dict "LoggedInUser" $root.LoggedInUser "UserDid" $follow.SubjectDid @@ -102,4 +116,25 @@ "FollowStatus" $followStatus "FollowersCount" $followStats.Followers "FollowingCount" $followStats.Following) }} +{{ end }} + +{{ define "timeline/fragments/othersBadge" }} + {{ $group := . }} + and {{ $group.OthersCount }} other user{{ if ne $group.OthersCount 1 }}s{{ end }} {{ end }}