From efbd55b3acaa6411627884c97c8502ebc1c18a3e Mon Sep 17 00:00:00 2001 From: Kevin Yap Date: Fri, 1 May 2026 16:08:59 -0400 Subject: [PATCH] appview/timeline: collapse identical events Group consecutive timeline events (defined as being the same action and operating on the same target) together so that the same target is not seen multiple times in a row. This notably does not completely aggregate all events together, so the timeline may still appear as "A B A" if several instances of event A were broken up by event B. For collapsed groups, we render "and N other user(s)" in the description of the event, with a popover that shows the collapsed users if "N other user(s)" is hovered. The number of events fetched was increased so that `limit` refers to the number of items in the timeline post-grouping. Signed-off-by: Kevin Yap --- appview/db/timeline.go | 54 +++++++++++-- appview/models/timeline.go | 18 +++++ appview/pages/pages.go | 2 +- .../timeline/fragments/timeline.html | 79 +++++++++++++------ 4 files changed, 122 insertions(+), 31 deletions(-) diff --git a/appview/db/timeline.go b/appview/db/timeline.go index 1da9ab61..41bd24a0 100644 --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -11,7 +11,7 @@ import ( // 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 @@ func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFol } } - 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 @@ func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFol 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 index 5f303a0c..11f43eaf 100644 --- a/appview/models/timeline.go +++ b/appview/models/timeline.go @@ -21,3 +21,21 @@ type TimelineEvent struct { 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 index a2331f96..17084261 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -403,7 +403,7 @@ func (p *Pages) Brand(w io.Writer, params BrandParams) error { 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 index efc0153f..5c03b92f 100644 --- 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 @@ -103,3 +117,24 @@ "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 }} -- 2.51.2