A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

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

Separate followed users from people recommendations

+100 -23
+1 -1
docs/specs.md
··· 267 267 268 268 Output: 269 269 feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }] 270 - people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] 270 + people: [{ did, handle, displayName, avatar, jaccard, commonFeeds, isFollowed }] 271 271 ``` 272 272 273 273 ### 3.9 AppView Jetstream Consumption
+10
internal/cluster/jaccard_test.go
··· 486 486 ctx := context.Background() 487 487 dbs := setupClusterTestDB(t) 488 488 seedClusterData(t, ctx, dbs) 489 + seedFollowData(t, ctx, dbs) 489 490 490 491 engine := newTestEngine(dbs) 491 492 assert.NilError(t, engine.ComputeUserSimilarity(ctx)) ··· 493 494 recs, err := engine.GetPeopleRecommendations(ctx, "did:test:carol", 10) 494 495 assert.NilError(t, err) 495 496 assert.Assert(t, len(recs) > 0, "carol should get people recommendations") 497 + 498 + for _, r := range recs { 499 + if r.DID == "did:test:dave" { 500 + assert.Assert(t, r.IsFollowed, "carol follows dave, should be marked as followed") 501 + } 502 + if r.DID == "did:test:alice" || r.DID == "did:test:bob" { 503 + assert.Assert(t, !r.IsFollowed, "carol does not follow %s, should not be marked as followed", r.DID) 504 + } 505 + } 496 506 } 497 507 498 508 func TestDismissArticle(t *testing.T) {
+9 -3
internal/cluster/scoring.go
··· 25 25 CommonFeeds int 26 26 CommonLikes int 27 27 CommonTags int 28 + IsFollowed bool 28 29 } 29 30 30 31 type ArticleRecommendation struct { ··· 458 459 func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { 459 460 rows, err := e.db.QueryContext(ctx, ` 460 461 SELECT u.did, 461 - sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0) 462 + sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0), 463 + CASE WHEN f.target_did IS NOT NULL THEN 1 ELSE 0 END 462 464 FROM ( 463 465 SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_a = ? 464 466 UNION ALL 465 467 SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_b = ? 466 468 ) sim 467 469 JOIN main.users u ON u.did = sim.peer_did 470 + LEFT JOIN main.follows f ON f.user_did = ? AND f.target_did = u.did 468 471 WHERE EXISTS (SELECT 1 FROM articles.subscriptions s JOIN articles.feeds f ON s.feed_url = f.feed_url WHERE s.user_did = u.did AND f.subscriber_count > 0) 469 472 ORDER BY sim.jaccard DESC 470 473 LIMIT ? 471 - `, userDID, userDID, limit) 474 + `, userDID, userDID, userDID, limit) 472 475 if err != nil { 473 476 return nil, err 474 477 } ··· 477 480 var results []*PersonRecommendation 478 481 for rows.Next() { 479 482 rec := &PersonRecommendation{} 483 + var isFollowed int 480 484 if err := rows.Scan(&rec.DID, 481 - &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil { 485 + &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags, 486 + &isFollowed); err != nil { 482 487 return nil, err 483 488 } 489 + rec.IsFollowed = isFollowed == 1 484 490 results = append(results, rec) 485 491 } 486 492 return results, rows.Err()
+11 -1
internal/server/dashboard_handler.go
··· 77 77 s.logger.Warn("failed to list global trending", "error", err, "did", user.DID) 78 78 } 79 79 80 + var followedPeople, discoverPeople []*cluster.PersonRecommendation 81 + for _, p := range peopleRecs { 82 + if p.IsFollowed { 83 + followedPeople = append(followedPeople, p) 84 + } else { 85 + discoverPeople = append(discoverPeople, p) 86 + } 87 + } 88 + 80 89 s.render(w, r, "dashboard.html", map[string]any{ 81 90 "User": user, 82 91 "UnreadCount": unreadCount, ··· 84 93 "Articles": articles, 85 94 "ArticleRecommendations": articleRecs, 86 95 "FeedRecommendations": feedRecs, 87 - "PeopleRecommendations": peopleRecs, 96 + "FollowedPeople": followedPeople, 97 + "DiscoverPeople": discoverPeople, 88 98 "PersonalTrending": personalTrending, 89 99 "GlobalTrending": globalTrending, 90 100 "Page": page,
+11 -1
internal/server/feeds_handler.go
··· 68 68 s.logger.Warn("failed to get categories", "error", err, "did", user.DID) 69 69 } 70 70 71 + var followedPeople, discoverPeople []*cluster.PersonRecommendation 72 + for _, p := range peopleRecs { 73 + if p.IsFollowed { 74 + followedPeople = append(followedPeople, p) 75 + } else { 76 + discoverPeople = append(discoverPeople, p) 77 + } 78 + } 79 + 71 80 s.render(w, r, "feeds.html", map[string]any{ 72 81 "User": user, 73 82 "Subscriptions": subs, ··· 75 84 "Categories": categories, 76 85 "Category": category, 77 86 "FeedRecommendations": feedRecs, 78 - "PeopleRecommendations": peopleRecs, 87 + "FollowedPeople": followedPeople, 88 + "DiscoverPeople": discoverPeople, 79 89 "DeadFeeds": deadFeeds, 80 90 "Page": page, 81 91 "BaseURL": "/feeds",
+35 -9
internal/tmpl/dashboard.html
··· 94 94 </div> 95 95 {{end}} 96 96 97 - {{if .PeopleRecommendations}} 97 + {{if or .FollowedPeople .DiscoverPeople}} 98 98 <div> 99 - <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2> 99 + {{if .FollowedPeople}} 100 + <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> 100 101 <div class="space-y-3"> 101 - {{range .PeopleRecommendations}} 102 + {{range .FollowedPeople}} 102 103 {{template "profile-card.html" .}} 103 104 {{end}} 104 105 </div> 106 + {{end}} 107 + {{if .DiscoverPeople}} 108 + <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-6{{end}}">Discover new readers</h2> 109 + <div class="space-y-3"> 110 + {{range .DiscoverPeople}} 111 + {{template "profile-card.html" .}} 112 + {{end}} 113 + </div> 114 + {{end}} 105 115 </div> 106 116 {{end}} 107 117 ··· 138 148 </div> 139 149 {{end}} 140 150 141 - {{if or .PersonalTrending .PeopleRecommendations .FeedRecommendations}} 151 + {{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}} 142 152 <div class="space-y-8"> 143 153 {{if .PersonalTrending}} 144 154 <div> ··· 154 164 </div> 155 165 {{end}} 156 166 157 - {{if .PeopleRecommendations}} 167 + {{if or .FollowedPeople .DiscoverPeople}} 158 168 <div> 159 - <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2> 160 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> 161 - {{range .PeopleRecommendations}} 162 - {{template "profile-card.html" .}} 169 + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> 170 + {{if .FollowedPeople}} 171 + <div> 172 + <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> 173 + <div class="space-y-3"> 174 + {{range .FollowedPeople}} 175 + {{template "profile-card.html" .}} 176 + {{end}} 177 + </div> 178 + </div> 179 + {{end}} 180 + {{if .DiscoverPeople}} 181 + <div> 182 + <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2> 183 + <div class="space-y-3"> 184 + {{range .DiscoverPeople}} 185 + {{template "profile-card.html" .}} 186 + {{end}} 187 + </div> 188 + </div> 163 189 {{end}} 164 190 </div> 165 191 </div>
+13 -3
internal/tmpl/feeds.html
··· 86 86 </div> 87 87 </div> 88 88 89 - {{if .PeopleRecommendations}} 89 + {{if or .FollowedPeople .DiscoverPeople}} 90 90 <div> 91 - <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-4">Similar readers</h2> 91 + {{if .FollowedPeople}} 92 + <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2> 92 93 <div class="space-y-3"> 93 - {{range .PeopleRecommendations}} 94 + {{range .FollowedPeople}} 94 95 {{template "profile-card.html" .}} 95 96 {{end}} 96 97 </div> 98 + {{end}} 99 + {{if .DiscoverPeople}} 100 + <h2 class="text-lg font-semibold text-spot-text mb-4 {{if .FollowedPeople}}mt-6{{end}}">Discover new readers</h2> 101 + <div class="space-y-3"> 102 + {{range .DiscoverPeople}} 103 + {{template "profile-card.html" .}} 104 + {{end}} 105 + </div> 106 + {{end}} 97 107 </div> 98 108 {{end}} 99 109
+9 -4
internal/tmpl/partials/profile-card.html
··· 1 1 {{define "profile-card.html"}} 2 - <div class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> 2 + <a href="/profile/{{.DID}}" class="bg-spot-surface rounded-xl p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> 3 3 {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}} 4 4 <div class="min-w-0 flex-1"> 5 - <a href="/profile/{{.DID}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a> 5 + <div class="flex items-center gap-1.5"> 6 + <span class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</span> 7 + {{if .IsFollowed}} 8 + <span class="inline-flex items-center gap-0.5 text-[10px] font-medium text-spot-green bg-spot-green/10 px-1.5 py-0.5 rounded-full">Following</span> 9 + {{end}} 10 + </div> 6 11 {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}} 7 12 </div> 8 - <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span> 9 - </div> 13 + <span class="text-xs text-spot-secondary shrink-0">{{.CommonFeeds}} shared</span> 14 + </a> 10 15 {{end}}
+1 -1
readme.md
··· 22 22 23 23 **Feed suggestions** come from readers who share your subscriptions. If a lot of people who follow the same blogs as you also follow a blog you haven't seen, that blog shows up as a recommendation. The system also considers which articles you've liked, whether you follow the person on Bluesky, and how popular the feed is overall. 24 24 25 - **People suggestions** are readers whose subscriptions overlap with yours. The more feeds you share, the higher they rank. You also see whether you have any Bluesky follows in common. 25 + **People suggestions** are split into two groups: "Your network" shows people you already follow on Bluesky who share your reading habits, and "Discover new readers" surfaces readers you don't follow but who have overlapping subscriptions and likes. 26 26 27 27 **Dismissals** keep things tidy. If you dismiss a recommendation, it won't come back. If a suggestion sits ignored for more than 5 days, it's automatically removed so newer recommendations can take its place. 28 28