Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <me@kevinyap.ca>

authored by

Kevin Yap and committed by
Tangled
efbd55b3 0488a703

+122 -31
+46 -8
appview/db/timeline.go
··· 11 11 12 12 // TODO: this gathers heterogenous events from different sources and aggregates 13 13 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 14 - func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { 14 + func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineGroup, error) { 15 15 var events []models.TimelineEvent 16 16 17 17 var userIsFollowing []string ··· 27 27 } 28 28 } 29 29 30 - repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) 30 + // Fetch more events than we need to so that when we collapse each individual 31 + // event into groups, we can still be relatively confident that we will have 32 + // `limit` groups to fill the timeline with. Adjust multiplier as necessary. 33 + fetchLimit := limit * 2 34 + 35 + repos, err := getTimelineRepos(e, fetchLimit, loggedInUserDid, userIsFollowing) 31 36 if err != nil { 32 37 return nil, err 33 38 } 34 39 35 - stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) 40 + stars, err := getTimelineStars(e, fetchLimit, loggedInUserDid, userIsFollowing) 36 41 if err != nil { 37 42 return nil, err 38 43 } 39 44 40 - follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) 45 + follows, err := getTimelineFollows(e, fetchLimit, loggedInUserDid, userIsFollowing) 41 46 if err != nil { 42 47 return nil, err 43 48 } ··· 50 55 return events[i].EventAt.After(events[j].EventAt) 51 56 }) 52 57 53 - // Limit the slice to 100 events 54 - if len(events) > limit { 55 - events = events[:limit] 58 + groups := collapseTimeline(events) 59 + if len(groups) > limit { 60 + groups = groups[:limit] 56 61 } 62 + return groups, nil 63 + } 57 64 58 - return events, nil 65 + // collapseTimeline merges consecutive events that share the same operation 66 + // and target into one TimelineGroup (assumes events are sorted newest-first). 67 + func collapseTimeline(events []models.TimelineEvent) []models.TimelineGroup { 68 + var groups []models.TimelineGroup 69 + i := 0 70 + for i < len(events) { 71 + group := models.TimelineGroup{Primary: events[i]} 72 + j := i + 1 73 + for j < len(events) && canCollapse(events[i], events[j]) { 74 + group.Others = append(group.Others, events[j]) 75 + j++ 76 + } 77 + groups = append(groups, group) 78 + i = j 79 + } 80 + return groups 81 + } 82 + 83 + // canCollapse reports whether two adjacent events in the timeline represent 84 + // the same operation on the same target (repo starred or user followed). 85 + func canCollapse(a, b models.TimelineEvent) bool { 86 + switch { 87 + case a.RepoStar != nil && b.RepoStar != nil: 88 + if a.RepoStar.Repo == nil || b.RepoStar.Repo == nil { 89 + return false 90 + } 91 + return a.RepoStar.Repo.RepoAt() == b.RepoStar.Repo.RepoAt() 92 + case a.Follow != nil && b.Follow != nil: 93 + return a.Follow.SubjectDid == b.Follow.SubjectDid 94 + default: 95 + return false 96 + } 59 97 } 60 98 61 99 func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
+18
appview/models/timeline.go
··· 21 21 IsStarred bool 22 22 StarCount int64 23 23 } 24 + 25 + // TimelineGroup is a primary TimelineEvent plus zero or more peer events 26 + // that share the same operation+target (same repo starred, same user 27 + // followed) and arrived consecutively. Primary is the newest of the group; 28 + // Others holds the older peers in descending order. For non-collapsible 29 + // events (repo create) Others is always empty. 30 + type TimelineGroup struct { 31 + Primary TimelineEvent 32 + Others []TimelineEvent 33 + } 34 + 35 + func (g TimelineGroup) IsCollapsed() bool { 36 + return len(g.Others) > 0 37 + } 38 + 39 + func (g TimelineGroup) OthersCount() int { 40 + return len(g.Others) 41 + }
+1 -1
appview/pages/pages.go
··· 403 403 404 404 type TimelineParams struct { 405 405 LoggedInUser *oauth.MultiAccountUser 406 - Timeline []models.TimelineEvent 406 + Timeline []models.TimelineGroup 407 407 Repos []models.Repo 408 408 GfiLabel *models.LabelDefinition 409 409 BlueskyPosts []models.BskyPost
+57 -22
appview/pages/templates/timeline/fragments/timeline.html
··· 6 6 </h3> 7 7 8 8 <div class="flex flex-col gap-4"> 9 - {{ range $i, $e := .Timeline }} 9 + {{ range $i, $g := .Timeline }} 10 10 <div class="relative"> 11 11 {{ if ne $i 0 }} 12 12 <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 13 13 {{ end }} 14 - {{ with $e }} 15 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 - {{ if .Repo }} 17 - {{ template "timeline/fragments/repoEvent" (list $ .) }} 18 - {{ else if .RepoStar }} 19 - {{ template "timeline/fragments/starEvent" (list $ .) }} 20 - {{ else if .Follow }} 21 - {{ template "timeline/fragments/followEvent" (list $ .) }} 22 - {{ end }} 23 - </div> 24 - {{ end }} 14 + {{ $primary := $g.Primary }} 15 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + {{ if $primary.Repo }} 17 + {{ template "timeline/fragments/repoEvent" (list $ $g) }} 18 + {{ else if $primary.RepoStar }} 19 + {{ template "timeline/fragments/starEvent" (list $ $g) }} 20 + {{ else if $primary.Follow }} 21 + {{ template "timeline/fragments/followEvent" (list $ $g) }} 22 + {{ end }} 23 + </div> 25 24 </div> 26 25 {{ end }} 27 26 </div> ··· 30 29 31 30 {{ define "timeline/fragments/repoEvent" }} 32 31 {{ $root := index . 0 }} 33 - {{ $event := index . 1 }} 32 + {{ $group := index . 1 }} 33 + {{ $event := $group.Primary }} 34 34 {{ $repo := $event.Repo }} 35 35 {{ $source := $event.Source }} 36 36 {{ $userHandle := resolve $repo.Did }} ··· 59 59 60 60 {{ define "timeline/fragments/starEvent" }} 61 61 {{ $root := index . 0 }} 62 - {{ $event := index . 1 }} 62 + {{ $group := index . 1 }} 63 + {{ $event := $group.Primary }} 63 64 {{ $star := $event.RepoStar }} 64 65 {{ with $star }} 65 - {{ $starrerHandle := resolve .Did }} 66 66 {{ $repoOwnerHandle := resolve .Repo.Did }} 67 67 <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 68 - {{ template "user/fragments/picHandleLink" $starrerHandle }} 69 - starred 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + {{ if $group.IsCollapsed }} 70 + <span class="inline-flex items-center gap-1.5"> 71 + {{ template "timeline/fragments/othersBadge" $group }} 72 + starred 73 + </span> 74 + {{ else }} 75 + starred 76 + {{ end }} 70 77 <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 71 78 {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 72 79 </a> ··· 80 87 81 88 {{ define "timeline/fragments/followEvent" }} 82 89 {{ $root := index . 0 }} 83 - {{ $event := index . 1 }} 90 + {{ $group := index . 1 }} 91 + {{ $event := $group.Primary }} 84 92 {{ $follow := $event.Follow }} 85 93 {{ $profile := $event.Profile }} 86 94 {{ $followStats := $event.FollowStats }} 87 95 {{ $followStatus := $event.FollowStatus }} 88 96 89 - {{ $userHandle := resolve $follow.UserDid }} 90 97 {{ $subjectHandle := resolve $follow.SubjectDid }} 91 98 <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 92 - {{ template "user/fragments/picHandleLink" $userHandle }} 93 - followed 99 + {{ template "user/fragments/picHandleLink" $follow.UserDid }} 100 + {{ if $group.IsCollapsed }} 101 + <span class="inline-flex items-center gap-1.5"> 102 + {{ template "timeline/fragments/othersBadge" $group }} 103 + followed 104 + </span> 105 + {{ else }} 106 + followed 107 + {{ end }} 94 108 {{ template "user/fragments/picHandleLink" $subjectHandle }} 95 109 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 96 110 </div> 97 - {{ template "user/fragments/followCard" 111 + {{ template "user/fragments/followCard" 98 112 (dict 99 113 "LoggedInUser" $root.LoggedInUser 100 114 "UserDid" $follow.SubjectDid ··· 103 117 "FollowersCount" $followStats.Followers 104 118 "FollowingCount" $followStats.Following) }} 105 119 {{ end }} 120 + 121 + {{ define "timeline/fragments/othersBadge" }} 122 + {{ $group := . }} 123 + <span class="relative group inline-flex items-center cursor-pointer" tabindex="0" 124 + >and&nbsp;<span class="underline decoration-dotted decoration-gray-400">{{ $group.OthersCount }} other user{{ if ne $group.OthersCount 1 }}s{{ end }}</span 125 + ><div class="absolute left-0 top-full pt-1 z-10 hidden group-hover:block group-focus-within:block"> 126 + <div class="min-w-48 max-w-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg p-2 cursor-default"> 127 + <ul class="flex flex-col gap-1"> 128 + {{ range $other := $group.Others }} 129 + <li class="flex items-center gap-2 text-sm cursor-pointer"> 130 + {{ if $other.RepoStar }} 131 + {{ template "user/fragments/picHandleLink" $other.RepoStar.Did }} 132 + {{ else if $other.Follow }} 133 + {{ template "user/fragments/picHandleLink" $other.Follow.UserDid }} 134 + {{ end }} 135 + </li> 136 + {{ end }} 137 + </ul> 138 + </div> 139 + </div></span> 140 + {{ end }}