Separate followed users from people recommendationsUnverified
16ed9f6 parent: 381f2e4 modified
docs/specs.md +1 -1 | @@ -267,7 +267,7 @@ Input: | ||
| 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 |
| @@ -267,7 +267,7 @@ Input: | |||
| 267 | 267 | ||
| 268 | Output: | 268 | Output: |
| 269 | feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }] | 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 | ### 3.9 AppView Jetstream Consumption | 273 | ### 3.9 AppView Jetstream Consumption |
modified
internal/cluster/jaccard_test.go +10 -0 | @@ -486,6 +486,7 @@ func TestOnDemandPeopleRecommendations(t *testing.T) { | ||
| 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,6 +494,15 @@ func TestOnDemandPeopleRecommendations(t *testing.T) { | ||
| 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) { |
| @@ -486,6 +486,7 @@ func TestOnDemandPeopleRecommendations(t *testing.T) { | |||
| 486 | ctx := context.Background() | 486 | ctx := context.Background() |
| 487 | dbs := setupClusterTestDB(t) | 487 | dbs := setupClusterTestDB(t) |
| 488 | seedClusterData(t, ctx, dbs) | 488 | seedClusterData(t, ctx, dbs) |
| 489 | + seedFollowData(t, ctx, dbs) | ||
| 489 | 490 | ||
| 490 | engine := newTestEngine(dbs) | 491 | engine := newTestEngine(dbs) |
| 491 | assert.NilError(t, engine.ComputeUserSimilarity(ctx)) | 492 | assert.NilError(t, engine.ComputeUserSimilarity(ctx)) |
| @@ -493,6 +494,15 @@ func TestOnDemandPeopleRecommendations(t *testing.T) { | |||
| 493 | recs, err := engine.GetPeopleRecommendations(ctx, "did:test:carol", 10) | 494 | recs, err := engine.GetPeopleRecommendations(ctx, "did:test:carol", 10) |
| 494 | assert.NilError(t, err) | 495 | assert.NilError(t, err) |
| 495 | assert.Assert(t, len(recs) > 0, "carol should get people recommendations") | 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 | func TestDismissArticle(t *testing.T) { | 508 | func TestDismissArticle(t *testing.T) { |
modified
internal/cluster/scoring.go +9 -3 | @@ -25,6 +25,7 @@ type PersonRecommendation struct { | ||
| 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,17 +459,19 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | ||
| 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,10 +480,13 @@ func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userD | ||
| 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() |
| @@ -25,6 +25,7 @@ type PersonRecommendation struct { | |||
| 25 | CommonFeeds int | 25 | CommonFeeds int |
| 26 | CommonLikes int | 26 | CommonLikes int |
| 27 | CommonTags int | 27 | CommonTags int |
| 28 | + IsFollowed bool | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | type ArticleRecommendation struct { | 31 | type ArticleRecommendation struct { |
| @@ -458,17 +459,19 @@ func (e *Engine) ComputeArticleRecommendationsOnDemand(ctx context.Context, user | |||
| 458 | func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { | 459 | func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { |
| 459 | rows, err := e.db.QueryContext(ctx, ` | 460 | rows, err := e.db.QueryContext(ctx, ` |
| 460 | SELECT u.did, | 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 | FROM ( | 464 | FROM ( |
| 463 | SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_a = ? | 465 | SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_a = ? |
| 464 | UNION ALL | 466 | UNION ALL |
| 465 | SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_b = ? | 467 | SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_b = ? |
| 466 | ) sim | 468 | ) sim |
| 467 | JOIN main.users u ON u.did = sim.peer_did | 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 | 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) | 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 | ORDER BY sim.jaccard DESC | 472 | ORDER BY sim.jaccard DESC |
| 470 | LIMIT ? | 473 | LIMIT ? |
| 471 | - `, userDID, userDID, limit) | 474 | + `, userDID, userDID, userDID, limit) |
| 472 | if err != nil { | 475 | if err != nil { |
| 473 | return nil, err | 476 | return nil, err |
| 474 | } | 477 | } |
| @@ -477,10 +480,13 @@ func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userD | |||
| 477 | var results []*PersonRecommendation | 480 | var results []*PersonRecommendation |
| 478 | for rows.Next() { | 481 | for rows.Next() { |
| 479 | rec := &PersonRecommendation{} | 482 | rec := &PersonRecommendation{} |
| 483 | + var isFollowed int | ||
| 480 | if err := rows.Scan(&rec.DID, | 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 | return nil, err | 487 | return nil, err |
| 483 | } | 488 | } |
| 489 | + rec.IsFollowed = isFollowed == 1 | ||
| 484 | results = append(results, rec) | 490 | results = append(results, rec) |
| 485 | } | 491 | } |
| 486 | return results, rows.Err() | 492 | return results, rows.Err() |
modified
internal/server/dashboard_handler.go +11 -1 | @@ -77,6 +77,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 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,7 +93,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 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, |
| @@ -77,6 +77,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 77 | s.logger.Warn("failed to list global trending", "error", err, "did", user.DID) | 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 | s.render(w, r, "dashboard.html", map[string]any{ | 89 | s.render(w, r, "dashboard.html", map[string]any{ |
| 81 | "User": user, | 90 | "User": user, |
| 82 | "UnreadCount": unreadCount, | 91 | "UnreadCount": unreadCount, |
| @@ -84,7 +93,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |||
| 84 | "Articles": articles, | 93 | "Articles": articles, |
| 85 | "ArticleRecommendations": articleRecs, | 94 | "ArticleRecommendations": articleRecs, |
| 86 | "FeedRecommendations": feedRecs, | 95 | "FeedRecommendations": feedRecs, |
| 87 | - "PeopleRecommendations": peopleRecs, | 96 | + "FollowedPeople": followedPeople, |
| 97 | + "DiscoverPeople": discoverPeople, | ||
| 88 | "PersonalTrending": personalTrending, | 98 | "PersonalTrending": personalTrending, |
| 89 | "GlobalTrending": globalTrending, | 99 | "GlobalTrending": globalTrending, |
| 90 | "Page": page, | 100 | "Page": page, |
modified
internal/server/feeds_handler.go +11 -1 | @@ -68,6 +68,15 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 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,7 +84,8 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 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", |
| @@ -68,6 +68,15 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 68 | s.logger.Warn("failed to get categories", "error", err, "did", user.DID) | 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 | s.render(w, r, "feeds.html", map[string]any{ | 80 | s.render(w, r, "feeds.html", map[string]any{ |
| 72 | "User": user, | 81 | "User": user, |
| 73 | "Subscriptions": subs, | 82 | "Subscriptions": subs, |
| @@ -75,7 +84,8 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 75 | "Categories": categories, | 84 | "Categories": categories, |
| 76 | "Category": category, | 85 | "Category": category, |
| 77 | "FeedRecommendations": feedRecs, | 86 | "FeedRecommendations": feedRecs, |
| 78 | - "PeopleRecommendations": peopleRecs, | 87 | + "FollowedPeople": followedPeople, |
| 88 | + "DiscoverPeople": discoverPeople, | ||
| 79 | "DeadFeeds": deadFeeds, | 89 | "DeadFeeds": deadFeeds, |
| 80 | "Page": page, | 90 | "Page": page, |
| 81 | "BaseURL": "/feeds", | 91 | "BaseURL": "/feeds", |
modified
internal/tmpl/dashboard.html +35 -9 | @@ -94,14 +94,24 @@ | ||
| 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,7 +148,7 @@ | ||
| 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,12 +164,28 @@ | ||
| 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> |
| @@ -94,14 +94,24 @@ | |||
| 94 | </div> | 94 | </div> |
| 95 | {{end}} | 95 | {{end}} |
| 96 | 96 | ||
| 97 | - {{if .PeopleRecommendations}} | 97 | + {{if or .FollowedPeople .DiscoverPeople}} |
| 98 | <div> | 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 | <div class="space-y-3"> | 101 | <div class="space-y-3"> |
| 101 | - {{range .PeopleRecommendations}} | 102 | + {{range .FollowedPeople}} |
| 102 | {{template "profile-card.html" .}} | 103 | {{template "profile-card.html" .}} |
| 103 | {{end}} | 104 | {{end}} |
| 104 | </div> | 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 | </div> | 115 | </div> |
| 106 | {{end}} | 116 | {{end}} |
| 107 | 117 | ||
| @@ -138,7 +148,7 @@ | |||
| 138 | </div> | 148 | </div> |
| 139 | {{end}} | 149 | {{end}} |
| 140 | 150 | ||
| 141 | -{{if or .PersonalTrending .PeopleRecommendations .FeedRecommendations}} | 151 | +{{if or .PersonalTrending (or .FollowedPeople .DiscoverPeople) .FeedRecommendations}} |
| 142 | <div class="space-y-8"> | 152 | <div class="space-y-8"> |
| 143 | {{if .PersonalTrending}} | 153 | {{if .PersonalTrending}} |
| 144 | <div> | 154 | <div> |
| @@ -154,12 +164,28 @@ | |||
| 154 | </div> | 164 | </div> |
| 155 | {{end}} | 165 | {{end}} |
| 156 | 166 | ||
| 157 | - {{if .PeopleRecommendations}} | 167 | + {{if or .FollowedPeople .DiscoverPeople}} |
| 158 | <div> | 168 | <div> |
| 159 | - <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2> | 169 | + <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> |
| 160 | - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> | 170 | + {{if .FollowedPeople}} |
| 161 | - {{range .PeopleRecommendations}} | 171 | + <div> |
| 162 | - {{template "profile-card.html" .}} | 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 | {{end}} | 189 | {{end}} |
| 164 | </div> | 190 | </div> |
| 165 | </div> | 191 | </div> |
modified
internal/tmpl/feeds.html +13 -3 | @@ -86,14 +86,24 @@ function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list', | ||
| 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 | |
| @@ -86,14 +86,24 @@ function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list', | |||
| 86 | </div> | 86 | </div> |
| 87 | </div> | 87 | </div> |
| 88 | 88 | ||
| 89 | - {{if .PeopleRecommendations}} | 89 | + {{if or .FollowedPeople .DiscoverPeople}} |
| 90 | <div> | 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 | <div class="space-y-3"> | 93 | <div class="space-y-3"> |
| 93 | - {{range .PeopleRecommendations}} | 94 | + {{range .FollowedPeople}} |
| 94 | {{template "profile-card.html" .}} | 95 | {{template "profile-card.html" .}} |
| 95 | {{end}} | 96 | {{end}} |
| 96 | </div> | 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 | </div> | 107 | </div> |
| 98 | {{end}} | 108 | {{end}} |
| 99 | 109 | ||
modified
internal/tmpl/partials/profile-card.html +9 -4 | @@ -1,10 +1,15 @@ | ||
| 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,10 +1,15 @@ | |||
| 1 | {{define "profile-card.html"}} | 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 | {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}} | 3 | {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full">{{end}} |
| 4 | <div class="min-w-0 flex-1"> | 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 | {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}} | 11 | {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}} |
| 7 | </div> | 12 | </div> |
| 8 | - <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span> | 13 | + <span class="text-xs text-spot-secondary shrink-0">{{.CommonFeeds}} shared</span> |
| 9 | -</div> | 14 | +</a> |
| 10 | {{end}} | 15 | {{end}} |
modified
readme.md +1 -1 | @@ -22,7 +22,7 @@ Glean looks at what you and other users subscribe to, read, and like to suggest | ||
| 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 | |
| @@ -22,7 +22,7 @@ Glean looks at what you and other users subscribe to, read, and like to suggest | |||
| 22 | 22 | ||
| 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. | 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 | **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. | 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 | ||