Monorepo for Tangled tangled.org
2

Configure Feed

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

appview/timeline: add recently visited pages to timeline

Signed-off-by: oppiliappan <me@oppi.li>

author
oppiliappan
committer
Tangled
date (Jun 8, 2026, 5:59 PM +0300) commit 02fb64d6 parent 46fa6326 change-id qnrtkluv
+192 -6
+8
appview/pages/pages.go
··· 400 400 return p.execute("brand/brand", w, params) 401 401 } 402 402 403 + type RecentItem struct { 404 + Link *models.RecentLink 405 + Repo *models.Repo 406 + Issue *models.Issue 407 + Pull *models.Pull 408 + } 409 + 403 410 type TimelineParams struct { 404 411 LoggedInUser *oauth.MultiAccountUser 405 412 Timeline []models.TimelineGroup ··· 408 415 BlueskyPosts []models.BskyPost 409 416 VouchSuggestions []models.VouchSuggestion 410 417 Notifications []*models.NotificationWithEntity 418 + Recents []RecentItem 411 419 // ShowNewsletter controls whether the newsletter widget/CTA is rendered. 412 420 // For logged-in users it reflects their newsletter_preferences row; for 413 421 // anonymous visitors it is always true (dismissal falls back to
+1 -2
appview/pages/templates/notifications/fragments/item.html
··· 5 5 <div 6 6 class=" 7 7 w-full mx-auto dark:text-white px-2 md:px-6 py-4 8 - {{ if not .Read }}bg-indigo-50/50 dark:bg-indigo-950/20 hover:bg-indigo-50 dark:hover:bg-indigo-950/50{{ else }}bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700/30{{ end }} 9 - transition-colors duration-150 8 + {{ if not .Read }}bg-indigo-50/50 dark:bg-indigo-950/20 hover:bg-gray-100/50 dark:hover:bg-gray-700/50{{ else }}bg-white dark:bg-gray-800 hover:bg-gray-100/50 dark:hover:bg-gray-700/50{{ end }} 10 9 grid grid-cols-[auto_1fr_auto] gap-x-4 gap-y-1 items-center 11 10 "> 12 11 {{/* row 1, col 1: icon */}}
+84
appview/pages/templates/timeline/fragments/recents.html
··· 1 + {{ define "timeline/fragments/recents" }} 2 + {{ if .LoggedInUser }} 3 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm divide-y divide-gray-200 dark:divide-gray-700"> 4 + <h3 class="text-base dark:text-white flex items-center gap-2 px-4 py-2 bg-white/50 dark:bg-gray-800/50"> 5 + {{ i "history" "size-4 flex-shrink-0" }} 6 + Recents 7 + </h3> 8 + 9 + {{ if not .Recents }} 10 + <div class="px-4 py-8 text-sm text-center text-gray-400 dark:text-gray-500"> 11 + <div class="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600"> 12 + {{ i "route" "size-10" }} 13 + </div> 14 + <p class="text-sm text-gray-500 dark:text-gray-400">No recent pages yet.</p> 15 + </div> 16 + {{ else }} 17 + <div class="relative flex flex-col bg-white dark:bg-gray-800"> 18 + <div class="absolute top-0 bottom-0 w-px bg-gray-200 dark:bg-gray-700" style="left: 1.875rem"></div> 19 + {{ range .Recents }} 20 + {{ if .Repo }} 21 + {{ template "timeline/fragments/recentRepo" . }} 22 + {{ else if .Issue }} 23 + {{ template "timeline/fragments/recentIssue" . }} 24 + {{ else if .Pull }} 25 + {{ template "timeline/fragments/recentPull" . }} 26 + {{ end }} 27 + {{ end }} 28 + </div> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + {{ end }} 33 + 34 + {{ define "recentRowClass" }} 35 + relative grid grid-cols-[1.75rem_1fr_auto] grid-rows-2 gap-x-3 pl-4 pr-4 py-3 text-sm no-underline hover:no-underline hover:bg-gray-100/50 dark:hover:bg-gray-700/50 overflow-hidden 36 + {{ end }} 37 + 38 + {{/* call with dict "color" "text-*" "icon" "icon-name" */}} 39 + {{ define "recentIconCircle" }} 40 + <span class="col-start-1 row-start-1 row-span-2 self-center relative z-10 flex items-center justify-center w-7 h-7 rounded-full bg-white dark:bg-gray-800 {{ .color }} shrink-0 border border-gray-200 dark:border-gray-700"> 41 + {{ i .icon "w-3.5 h-3.5" }} 42 + </span> 43 + {{ end }} 44 + 45 + {{ define "timeline/fragments/recentRepo" }} 46 + {{ $repoOwner := resolve .Repo.Did }} 47 + <a href="/{{ $repoOwner }}/{{ .Repo.Slug }}" class="{{ template "recentRowClass" }}"> 48 + <span class="col-start-1 row-start-1 row-span-2 self-center relative z-10"> 49 + {{ template "user/fragments/pic" (list .Repo.Did "size-7") }} 50 + </span> 51 + <span class="col-start-2 row-start-1 row-span-2 self-center truncate font-medium dark:text-white">{{ $repoOwner }}/{{ .Repo.Name }}</span> 52 + <span class="col-start-3 row-start-1 row-span-2 self-center text-gray-500 dark:text-gray-400 text-xs shrink-0">{{ template "repo/fragments/shortTime" .Link.Visited }}</span> 53 + </a> 54 + {{ end }} 55 + 56 + {{ define "timeline/fragments/recentIssue" }} 57 + {{ $repoOwner := resolve .Issue.Repo.Did }} 58 + <a href="/{{ $repoOwner }}/{{ .Issue.Repo.Slug }}/issues/{{ .Issue.IssueId }}" class="{{ template "recentRowClass" }}"> 59 + {{ if .Issue.Open }} 60 + {{ template "recentIconCircle" (dict "color" "text-green-600 dark:text-green-500" "icon" "circle-dot") }} 61 + {{ else }} 62 + {{ template "recentIconCircle" (dict "color" "text-gray-500 dark:text-gray-400" "icon" "ban") }} 63 + {{ end }} 64 + <span class="col-start-2 row-start-1 truncate dark:text-white">{{ .Issue.Title }}</span> 65 + <span class="col-start-2 row-start-2 truncate text-xs text-gray-500 dark:text-gray-400">{{ $repoOwner }}/{{ .Issue.Repo.Name }}</span> 66 + <span class="col-start-3 row-start-1 self-center text-gray-500 dark:text-gray-400 text-xs shrink-0">{{ template "repo/fragments/shortTime" .Link.Visited }}</span> 67 + </a> 68 + {{ end }} 69 + 70 + {{ define "timeline/fragments/recentPull" }} 71 + {{ $repoOwner := resolve .Pull.Repo.Did }} 72 + <a href="/{{ $repoOwner }}/{{ .Pull.Repo.Slug }}/pulls/{{ .Pull.PullId }}" class="{{ template "recentRowClass" }}"> 73 + {{ if .Pull.State.IsOpen }} 74 + {{ template "recentIconCircle" (dict "color" "text-green-600 dark:text-green-500" "icon" "git-pull-request") }} 75 + {{ else if .Pull.State.IsMerged }} 76 + {{ template "recentIconCircle" (dict "color" "text-purple-600 dark:text-purple-500" "icon" "git-merge") }} 77 + {{ else }} 78 + {{ template "recentIconCircle" (dict "color" "text-gray-600 dark:text-gray-300" "icon" "git-pull-request-closed") }} 79 + {{ end }} 80 + <span class="col-start-2 row-start-1 truncate dark:text-white">{{ .Pull.Title }}</span> 81 + <span class="col-start-2 row-start-2 truncate text-xs text-gray-500 dark:text-gray-400">{{ $repoOwner }}/{{ .Pull.Repo.Name }}</span> 82 + <span class="col-start-3 row-start-1 self-center text-gray-500 dark:text-gray-400 text-xs shrink-0">{{ template "repo/fragments/shortTime" .Link.Visited }}</span> 83 + </a> 84 + {{ end }}
+2 -2
appview/pages/templates/timeline/fragments/vouchSuggestions.html
··· 8 8 </h3> 9 9 <span class="text-sm font-normal flex items-center gap-1 pr-4"> 10 10 View all {{ i "arrow-right" "size-3.5" }} 11 - </a> 12 - </div> 11 + </span> 12 + </a> 13 13 <div class="flex flex-col gap-4 bg-white dark:bg-gray-800 p-4"> 14 14 {{ range .VouchSuggestions }} 15 15 <div class="flex items-center gap-3 ">
+2 -1
appview/pages/templates/timeline/timeline.html
··· 16 16 {{ define "content" }} 17 17 <div id="timeline-grid" class="flex flex-col md:grid md:grid-cols-8"> 18 18 19 - <div class="hidden md:flex md:col-span-2 md:flex-col md:gap-6 md:pt-4 md:px-4"> 19 + <div class="hidden md:flex md:col-span-2 md:flex-col md:gap-4 md:pt-4 md:px-4"> 20 20 {{ template "timeline/fragments/notifications" . }} 21 + {{ template "timeline/fragments/recents" . }} 21 22 </div> 22 23 23 24 <div class="order-2 md:order-none md:col-start-3 md:row-start-1 md:col-span-4">
+95 -1
appview/state/timeline.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "sort" 5 6 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 7 8 "tangled.org/core/appview/db" ··· 79 80 s.db, 80 81 pagination.Page{Limit: 5, Offset: 0}, 81 82 orm.FilterEq("recipient_did", user.Did), 82 - orm.FilterEq("read", 0), 83 83 ) 84 84 if err != nil { 85 85 s.logger.Error("failed to get notifications for timeline", "err", err) ··· 108 108 } 109 109 } 110 110 111 + var recents []pages.RecentItem 112 + if user != nil { 113 + recents, err = s.buildRecents(user.Did) 114 + if err != nil { 115 + s.logger.Error("failed to build recents for timeline", "err", err) 116 + } 117 + } 118 + 111 119 s.pages.Timeline(w, pages.TimelineParams{ 112 120 LoggedInUser: user, 113 121 Timeline: timeline, ··· 115 123 GfiLabel: gfiLabel, 116 124 VouchSuggestions: vouchSuggestions, 117 125 Notifications: notifications, 126 + Recents: recents, 118 127 ShowNewsletter: s.showNewsletter(user), 119 128 }) 129 + } 130 + 131 + func (s *State) buildRecents(userDid string) ([]pages.RecentItem, error) { 132 + links, err := db.GetRecentLinks(s.db, orm.FilterEq("user_did", userDid)) 133 + if err != nil { 134 + return nil, err 135 + } 136 + if len(links) == 0 { 137 + return nil, nil 138 + } 139 + 140 + // group targets by type. 141 + var repoDids, issueAtUris, pullAtUris []string 142 + for _, l := range links { 143 + switch l.LinkType { 144 + case models.RecentLinkTypeRepo: 145 + repoDids = append(repoDids, l.Target) 146 + case models.RecentLinkTypeIssue: 147 + issueAtUris = append(issueAtUris, l.Target) 148 + case models.RecentLinkTypePull: 149 + pullAtUris = append(pullAtUris, l.Target) 150 + } 151 + } 152 + 153 + // fetch repos by DID. 154 + repoByDid := make(map[string]*models.Repo) 155 + if len(repoDids) > 0 { 156 + fetched, err := db.GetRepos(s.db, orm.FilterIn("repo_did", repoDids)) 157 + if err != nil { 158 + return nil, err 159 + } 160 + for i := range fetched { 161 + repoByDid[fetched[i].RepoDid] = &fetched[i] 162 + } 163 + } 164 + 165 + // fetch issues by aturi 166 + issueByAtUri := make(map[string]*models.Issue) 167 + if len(issueAtUris) > 0 { 168 + issues, err := db.GetIssues(s.db, orm.FilterIn("at_uri", issueAtUris)) 169 + if err != nil { 170 + return nil, err 171 + } 172 + for _, issue := range issues { 173 + issueByAtUri[issue.AtUri().String()] = &issue 174 + } 175 + } 176 + 177 + // fetch pulls by aturi 178 + pullByAtUri := make(map[string]*models.Pull) 179 + if len(pullAtUris) > 0 { 180 + fetched, err := db.GetPulls(s.db, orm.FilterIn("at_uri", pullAtUris)) 181 + if err != nil { 182 + return nil, err 183 + } 184 + for _, p := range fetched { 185 + pullByAtUri[p.AtUri().String()] = p 186 + } 187 + } 188 + 189 + // build result in original link order 190 + var items []pages.RecentItem 191 + for _, l := range links { 192 + item := pages.RecentItem{Link: l} 193 + switch l.LinkType { 194 + case models.RecentLinkTypeRepo: 195 + item.Repo = repoByDid[l.Target] 196 + case models.RecentLinkTypeIssue: 197 + item.Issue = issueByAtUri[l.Target] 198 + case models.RecentLinkTypePull: 199 + item.Pull = pullByAtUri[l.Target] 200 + } 201 + // skip if the entity could not be resolved (e.g. deleted). 202 + if item.Repo == nil && item.Issue == nil && item.Pull == nil { 203 + continue 204 + } 205 + items = append(items, item) 206 + } 207 + 208 + // re-sort by visited descending to restore recency order after map lookups. 209 + sort.Slice(items, func(i, j int) bool { 210 + return items[i].Link.Visited.After(items[j].Link.Visited) 211 + }) 212 + 213 + return items, nil 120 214 } 121 215 122 216 // showNewsletter decides whether the newsletter widget/CTA should render.