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.

Simplify user table

+147 -258
+35 -6
internal/atproto/auth.go
··· 10 10 11 11 "github.com/bluesky-social/indigo/atproto/identity" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "pkg.rbrt.fr/glean/internal/httpclient" 13 14 ) 14 15 15 16 type ( ··· 26 27 base := identity.BaseDirectory{ 27 28 PLCURL: plcURL, 28 29 HTTPClient: http.Client{ 29 - Timeout: 10 * time.Second, 30 - Transport: &http.Transport{ 31 - Proxy: http.ProxyFromEnvironment, 32 - IdleConnTimeout: 1000 * time.Millisecond, 33 - MaxIdleConns: 100, 34 - }, 30 + Timeout: 10 * time.Second, 31 + Transport: httpclient.NewTransport(), 35 32 }, 36 33 Resolver: net.Resolver{ 37 34 Dial: func(ctx context.Context, network, address string) (net.Conn, error) { ··· 43 40 SkipDNSDomainSuffixes: []string{".bsky.social"}, 44 41 UserAgent: "glean/1.0", 45 42 } 43 + 46 44 directory = identity.NewCacheDirectory(&base, 250_000, 24*time.Hour, 2*time.Minute, 5*time.Minute) 47 45 } 48 46 ··· 113 111 114 112 return pds, nil 115 113 } 114 + 115 + type Profile struct { 116 + Handle string 117 + DisplayName string 118 + AvatarURL string 119 + } 120 + 121 + func ResolveProfile(ctx context.Context, did string) Profile { 122 + ident, err := ResolveIdentity(ctx, did) 123 + if err != nil { 124 + return Profile{} 125 + } 126 + 127 + p := Profile{ 128 + DisplayName: ident.Handle.String(), 129 + Handle: ident.Handle.String(), 130 + } 131 + 132 + h, dn, avatar, err := FetchProfile(ctx, did) 133 + if err != nil { 134 + return p 135 + } 136 + 137 + if h != "" { 138 + p.Handle = h 139 + } 140 + p.DisplayName = dn 141 + p.AvatarURL = avatar 142 + 143 + return p 144 + }
+4 -25
internal/atproto/sync.go
··· 12 12 "encoding/json" 13 13 "fmt" 14 14 "log/slog" 15 + "maps" 16 + "slices" 15 17 "time" 16 - 17 - "golang.org/x/sync/errgroup" 18 18 19 19 "pkg.rbrt.fr/glean/internal/db" 20 20 ) ··· 266 266 return nil 267 267 } 268 268 269 - g, gCtx := errgroup.WithContext(ctx) 270 - g.SetLimit(20) 271 - 272 - dids := make([]string, 0, len(activeFollows)) 273 - profiles := make([]db.UserData, len(activeFollows)) 274 - for did := range activeFollows { 275 - dids = append(dids, did) 269 + if err := s.users.BatchCreateUsers(ctx, slices.Collect(maps.Keys(activeFollows))); err != nil { 270 + return fmt.Errorf("batch create users: %w", err) 276 271 } 277 272 278 - for i, did := range dids { 279 - g.Go(func() error { 280 - if h, dn, avatar, err := FetchProfile(gCtx, did); err == nil { 281 - profiles[i] = db.UserData{DID: did, Handle: h, DisplayName: dn, AvatarURL: avatar} 282 - } else { 283 - profiles[i] = db.UserData{DID: did} 284 - } 285 - return nil 286 - }) 287 - } 288 - if err := g.Wait(); err != nil { 289 - return fmt.Errorf("fetch profiles: %w", err) 290 - } 291 - if err := s.users.BatchCreateUsers(ctx, profiles); err != nil { 292 - return fmt.Errorf("batch create users: %w", err) 293 - } 294 273 return s.users.SyncFollows(ctx, userDID, activeFollows) 295 274 }
+12 -12
internal/atproto/xrpc.go
··· 93 93 cursor := r.URL.Query().Get("cursor") 94 94 95 95 query := ` 96 - SELECT a.uri, a.cid, u.did, u.handle, a.feed_url, a.article_url, 96 + SELECT a.uri, a.cid, u.did, a.feed_url, a.article_url, 97 97 a.quote, a.note, a.tags, a.rating, a.created_at 98 98 FROM annotations a 99 99 JOIN users u ON a.author_did = u.did ··· 129 129 130 130 annotations := make([]AnnotationView, 0) 131 131 for rows.Next() { 132 - var uri, did, handle, fURL, artURL, createdAt string 132 + var uri, did, fURL, artURL, createdAt string 133 133 var cid, quote, note, tags sql.NullString 134 134 var rating sql.NullInt64 135 - if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil { 135 + if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &quote, &note, &tags, &rating, &createdAt); err != nil { 136 136 http.Error(w, err.Error(), http.StatusInternalServerError) 137 137 return 138 138 } ··· 147 147 CID: cid.String, 148 148 Author: ActorView{ 149 149 DID: did, 150 - Handle: handle, 150 + Handle: ResolveProfile(r.Context(), did).Handle, 151 151 }, 152 152 Value: AnnotationRecord{ 153 153 CreatedAt: createdAt, ··· 179 179 cursor := r.URL.Query().Get("cursor") 180 180 181 181 query := ` 182 - SELECT l.uri, l.cid, u.did, u.handle, l.feed_url, l.article_url, l.created_at 182 + SELECT l.uri, l.cid, u.did, l.feed_url, l.article_url, l.created_at 183 183 FROM likes l 184 184 JOIN users u ON l.author_did = u.did 185 185 WHERE 1=1` ··· 210 210 211 211 likes := make([]LikeView, 0) 212 212 for rows.Next() { 213 - var uri, did, handle, fURL, artURL, createdAt string 213 + var uri, did, fURL, artURL, createdAt string 214 214 var cid sql.NullString 215 - if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &createdAt); err != nil { 215 + if err := rows.Scan(&uri, &cid, &did, &fURL, &artURL, &createdAt); err != nil { 216 216 http.Error(w, err.Error(), http.StatusInternalServerError) 217 217 return 218 218 } ··· 222 222 CID: cid.String, 223 223 Author: ActorView{ 224 224 DID: did, 225 - Handle: handle, 225 + Handle: ResolveProfile(r.Context(), did).Handle, 226 226 }, 227 227 Value: LikeRecord{ 228 228 CreatedAt: createdAt, ··· 336 336 for _, rec := range peopleRecs { 337 337 people = append(people, RecommendedPerson{ 338 338 DID: rec.DID, 339 - Handle: rec.Handle, 339 + Handle: ResolveProfile(r.Context(), rec.DID).Handle, 340 340 DisplayName: rec.DisplayName, 341 341 Avatar: rec.AvatarURL, 342 342 Jaccard: rec.Jaccard, ··· 370 370 } 371 371 372 372 query := ` 373 - SELECT u.did, u.handle, COUNT(s.id) as subscription_count 373 + SELECT u.did, COUNT(s.id) as subscription_count 374 374 FROM users u 375 375 LEFT JOIN subscriptions s ON u.did = s.user_did 376 376 WHERE u.did IN (` + strings.Join(placeholders, ",") + `)` ··· 398 398 var users []userRow 399 399 400 400 for rows.Next() { 401 - var did, handle string 401 + var did string 402 402 var subCount int 403 - if err := rows.Scan(&did, &handle, &subCount); err != nil { 403 + if err := rows.Scan(&did, &subCount); err != nil { 404 404 http.Error(w, err.Error(), http.StatusInternalServerError) 405 405 return 406 406 }
+6 -10
internal/cluster/jaccard_test.go
··· 40 40 func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) { 41 41 t.Helper() 42 42 43 - users := []struct{ did, handle string }{ 44 - {"did:test:alice", "alice"}, 45 - {"did:test:bob", "bob"}, 46 - {"did:test:carol", "carol"}, 47 - } 48 - for _, u := range users { 49 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, u.did, u.handle) 43 + users := []string{"did:test:alice", "did:test:bob", "did:test:carol"} 44 + for _, did := range users { 45 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, did) 50 46 assert.NilError(t, err) 51 47 } 52 48 ··· 430 426 _, err = dbs.DB().ExecContext(ctx, `UPDATE articles.feeds SET subscriber_count = 2 WHERE feed_url = 'https://b.com/feed'`) 431 427 assert.NilError(t, err) 432 428 433 - _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:newuser", "newuser") 429 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:newuser") 434 430 assert.NilError(t, err) 435 431 436 432 recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10) ··· 513 509 ctx := context.Background() 514 510 dbs := setupClusterTestDB(t) 515 511 516 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:alice", "alice") 512 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:alice") 517 513 assert.NilError(t, err) 518 - _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:bob", "bob") 514 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, "did:test:bob") 519 515 assert.NilError(t, err) 520 516 521 517 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`,
+3 -4
internal/cluster/scoring.go
··· 248 248 249 249 func (e *Engine) ComputePeopleRecommendationsOnDemand(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { 250 250 rows, err := e.db.QueryContext(ctx, ` 251 - SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''), 251 + SELECT u.did, 252 252 sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0) 253 253 FROM ( 254 254 SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_a = ? ··· 256 256 SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_b = ? 257 257 ) sim 258 258 JOIN main.users u ON u.did = sim.peer_did 259 - WHERE u.handle IS NOT NULL AND u.handle != '' 260 - AND 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) 259 + 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) 261 260 ORDER BY sim.jaccard DESC 262 261 LIMIT ? 263 262 `, userDID, userDID, limit) ··· 269 268 var results []*PersonRecommendation 270 269 for rows.Next() { 271 270 rec := &PersonRecommendation{} 272 - if err := rows.Scan(&rec.DID, &rec.Handle, &rec.DisplayName, &rec.AvatarURL, 271 + if err := rows.Scan(&rec.DID, 273 272 &rec.Jaccard, &rec.CommonFeeds, &rec.CommonLikes, &rec.CommonTags); err != nil { 274 273 return nil, err 275 274 }
+2 -2
internal/db/article_test.go
··· 33 33 userDID = "did:test:user1" 34 34 feedURL = "https://example.com/feed.xml" 35 35 36 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "user1") 36 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, userDID) 37 37 assert.NilError(t, err) 38 38 39 39 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Test Feed") ··· 196 196 userDID = "did:test:searcher" 197 197 feedURL = "https://search.example.com/feed.xml" 198 198 199 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher") 199 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, userDID) 200 200 assert.NilError(t, err) 201 201 202 202 _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog")
+8 -38
internal/db/batch_test.go
··· 12 12 ctx := context.Background() 13 13 dbs := setupTestDB(t) 14 14 15 - data := []UserData{ 16 - {DID: "did:test:u1", Handle: "user1", DisplayName: "User One", AvatarURL: "https://avatar1.png"}, 17 - {DID: "did:test:u2", Handle: "user2", DisplayName: "User Two"}, 18 - } 19 - err := dbs.Users.BatchCreateUsers(ctx, data) 15 + err := dbs.Users.BatchCreateUsers(ctx, []string{"did:test:u1", "did:test:u2"}) 20 16 assert.NilError(t, err) 21 17 22 18 u1, err := dbs.Users.GetUser(ctx, "did:test:u1") 23 19 assert.NilError(t, err) 24 - assert.Equal(t, u1.Handle, "user1") 25 - assert.Equal(t, u1.DisplayName.String, "User One") 20 + assert.Equal(t, u1.DID, "did:test:u1") 26 21 27 22 u2, err := dbs.Users.GetUser(ctx, "did:test:u2") 28 23 assert.NilError(t, err) 29 - assert.Equal(t, u2.Handle, "user2") 30 - assert.Equal(t, u2.DisplayName.String, "User Two") 24 + assert.Equal(t, u2.DID, "did:test:u2") 31 25 } 32 26 33 - func TestBatchCreateUsers_UpsertsExisting(t *testing.T) { 27 + func TestBatchCreateUsers_IgnoresExisting(t *testing.T) { 34 28 ctx := context.Background() 35 29 dbs := setupTestDB(t) 36 30 37 - _, err := dbs.Users.CreateUser(ctx, "did:test:u1", "old-handle", "", "") 31 + _, err := dbs.Users.CreateUser(ctx, "did:test:u1") 38 32 assert.NilError(t, err) 39 33 40 - data := []UserData{ 41 - {DID: "did:test:u1", Handle: "new-handle", DisplayName: "New Name"}, 42 - } 43 - err = dbs.Users.BatchCreateUsers(ctx, data) 34 + err = dbs.Users.BatchCreateUsers(ctx, []string{"did:test:u1"}) 44 35 assert.NilError(t, err) 45 36 46 37 u, err := dbs.Users.GetUser(ctx, "did:test:u1") 47 38 assert.NilError(t, err) 48 - assert.Equal(t, u.Handle, "new-handle") 49 - assert.Equal(t, u.DisplayName.String, "New Name") 39 + assert.Equal(t, u.DID, "did:test:u1") 50 40 } 51 41 52 42 func TestBatchCreateUsers_Empty(t *testing.T) { ··· 57 47 assert.NilError(t, err) 58 48 } 59 49 60 - func TestBatchCreateUsers_DoesNotOverwriteWithEmpty(t *testing.T) { 61 - ctx := context.Background() 62 - dbs := setupTestDB(t) 63 - 64 - _, err := dbs.Users.CreateUser(ctx, "did:test:u1", "handle", "Existing Name", "https://avatar.png") 65 - assert.NilError(t, err) 66 - 67 - data := []UserData{ 68 - {DID: "did:test:u1", Handle: "", DisplayName: "", AvatarURL: ""}, 69 - } 70 - err = dbs.Users.BatchCreateUsers(ctx, data) 71 - assert.NilError(t, err) 72 - 73 - u, err := dbs.Users.GetUser(ctx, "did:test:u1") 74 - assert.NilError(t, err) 75 - assert.Equal(t, u.Handle, "handle") 76 - assert.Equal(t, u.DisplayName.String, "Existing Name") 77 - assert.Equal(t, u.AvatarURL.String, "https://avatar.png") 78 - } 79 - 80 50 func seedSubscriptionData(t *testing.T, ctx context.Context, dbs *Databases) (userDID string) { 81 51 t.Helper() 82 52 userDID = "did:test:subuser" 83 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "subuser") 53 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, userDID) 84 54 assert.NilError(t, err) 85 55 return userDID 86 56 }
-4
internal/db/db.go
··· 93 93 var schema = []string{ 94 94 `CREATE TABLE IF NOT EXISTS users ( 95 95 did TEXT PRIMARY KEY, 96 - handle TEXT NOT NULL, 97 - display_name TEXT, 98 - avatar_url TEXT, 99 96 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 100 97 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 101 98 )`, ··· 274 271 `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`, 275 272 `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`, 276 273 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`, 277 - `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, 278 274 `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`, 279 275 `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN 280 276 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
+5 -5
internal/db/follow_test.go
··· 13 13 userDID = "did:test:follower" 14 14 targetDID = "did:test:followed" 15 15 16 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "follower") 16 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, userDID) 17 17 assert.NilError(t, err) 18 - _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, targetDID, "followed") 18 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, targetDID) 19 19 assert.NilError(t, err) 20 20 21 21 return userDID, targetDID ··· 83 83 userDID, _ := seedFollowData(t, ctx, dbs) 84 84 85 85 target2 := "did:test:followed2" 86 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 86 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, target2) 87 87 assert.NilError(t, err) 88 88 89 89 err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1") ··· 102 102 _, targetDID := seedFollowData(t, ctx, dbs) 103 103 104 104 follower2 := "did:test:follower2" 105 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, follower2, "follower2") 105 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, follower2) 106 106 assert.NilError(t, err) 107 107 108 108 err = dbs.Users.UpsertFollow(ctx, "did:test:follower", targetDID, "uri1", "cid1") ··· 121 121 userDID, _ := seedFollowData(t, ctx, dbs) 122 122 123 123 target2 := "did:test:followed2" 124 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 124 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did) VALUES (?)`, target2) 125 125 assert.NilError(t, err) 126 126 127 127 err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1")
-4
internal/db/multi.go
··· 125 125 var usersSchema = []string{ 126 126 `CREATE TABLE IF NOT EXISTS users ( 127 127 did TEXT PRIMARY KEY, 128 - handle TEXT NOT NULL, 129 - display_name TEXT, 130 - avatar_url TEXT, 131 128 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 132 129 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 133 130 )`, ··· 157 154 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, 158 155 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, 159 156 `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`, 160 - `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, 161 157 } 162 158 163 159 var articlesSchema = []string{
+4 -6
internal/db/social.go
··· 42 42 func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { 43 43 a := &Annotation{} 44 44 err := s.db.QueryRowContext(ctx, ` 45 - SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 45 + SELECT a.id, a.uri, a.author_did, a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 46 46 FROM articles.annotations a 47 - LEFT JOIN users u ON a.author_did = u.did 48 47 LEFT JOIN articles.articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url 49 48 WHERE a.id = ? 50 - `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID, 49 + `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, &a.ArticleID, 51 50 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID) 52 51 if err != nil { 53 52 return nil, err ··· 89 88 args = append(args, authorDID) 90 89 } 91 90 92 - query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 91 + query := `SELECT a.id, a.uri, a.author_did, a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 93 92 FROM articles.annotations a 94 - LEFT JOIN users u ON a.author_did = u.did 95 93 LEFT JOIN articles.articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url` 96 94 if len(conds) > 0 { 97 95 query += ` WHERE ` + strings.Join(conds, " AND ") ··· 108 106 var annotations []*Annotation 109 107 for rows.Next() { 110 108 a := &Annotation{} 111 - if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID, 109 + if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, &a.ArticleID, 112 110 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { 113 111 return nil, err 114 112 }
+14 -49
internal/db/user.go
··· 8 8 type User struct { 9 9 DID string 10 10 Handle string 11 - DisplayName sql.NullString 12 - AvatarURL sql.NullString 13 - IndexedAt sql.NullTime 14 - UpdatedAt sql.NullTime 15 - } 16 - 17 - type UserData struct { 18 - DID string 19 - Handle string 20 11 DisplayName string 21 12 AvatarURL string 13 + IndexedAt sql.NullTime 14 + UpdatedAt sql.NullTime 22 15 } 23 16 24 17 type UserStore struct { ··· 29 22 return &UserStore{db: db} 30 23 } 31 24 32 - func (s *UserStore) BatchCreateUsers(ctx context.Context, users []UserData) error { 33 - if len(users) == 0 { 25 + func (s *UserStore) BatchCreateUsers(ctx context.Context, dids []string) error { 26 + if len(dids) == 0 { 34 27 return nil 35 28 } 36 29 tx, err := s.db.BeginTx(ctx, nil) ··· 40 33 defer tx.Rollback() 41 34 42 35 stmt, err := tx.PrepareContext(ctx, ` 43 - INSERT INTO users (did, handle, display_name, avatar_url, updated_at) 44 - VALUES (?, COALESCE(NULLIF(?, ''), ?), NULLIF(?, ''), NULLIF(?, ''), CURRENT_TIMESTAMP) 45 - ON CONFLICT(did) DO UPDATE SET 46 - handle = COALESCE(NULLIF(excluded.handle, ''), users.handle), 47 - display_name = COALESCE(NULLIF(excluded.display_name, ''), users.display_name), 48 - avatar_url = COALESCE(NULLIF(excluded.avatar_url, ''), users.avatar_url), 49 - updated_at = CURRENT_TIMESTAMP 36 + INSERT OR IGNORE INTO users (did, updated_at) 37 + VALUES (?, CURRENT_TIMESTAMP) 50 38 `) 51 39 if err != nil { 52 40 return err 53 41 } 54 42 defer stmt.Close() 55 43 56 - for _, u := range users { 57 - if _, err := stmt.ExecContext(ctx, u.DID, u.Handle, u.DID, u.DisplayName, u.AvatarURL); err != nil { 44 + for _, did := range dids { 45 + if _, err := stmt.ExecContext(ctx, did); err != nil { 58 46 return err 59 47 } 60 48 } 61 49 return tx.Commit() 62 50 } 63 51 64 - func (s *UserStore) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) { 65 - err := s.BatchCreateUsers(ctx, []UserData{{DID: did, Handle: handle, DisplayName: displayName, AvatarURL: avatarURL}}) 52 + func (s *UserStore) CreateUser(ctx context.Context, did string) (*User, error) { 53 + err := s.BatchCreateUsers(ctx, []string{did}) 66 54 if err != nil { 67 55 return nil, err 68 56 } ··· 72 60 func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) { 73 61 u := &User{} 74 62 err := s.db.QueryRowContext(ctx, ` 75 - SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 63 + SELECT did, indexed_at, updated_at 76 64 FROM users WHERE did = ? 77 - `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) 78 - if err != nil { 79 - return nil, err 80 - } 81 - return u, nil 82 - } 83 - 84 - func (s *UserStore) GetUserByHandle(ctx context.Context, handle string) (*User, error) { 85 - u := &User{} 86 - err := s.db.QueryRowContext(ctx, ` 87 - SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 88 - FROM users WHERE handle = ? 89 - `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) 65 + `, did).Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt) 90 66 if err != nil { 91 67 return nil, err 92 68 } ··· 111 87 return dids, rows.Err() 112 88 } 113 89 114 - func (s *UserStore) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL string) error { 115 - _, err := s.db.ExecContext(ctx, ` 116 - UPDATE users SET 117 - display_name = COALESCE(NULLIF(?, ''), display_name), 118 - avatar_url = COALESCE(NULLIF(?, ''), avatar_url), 119 - updated_at = CURRENT_TIMESTAMP 120 - WHERE did = ? 121 - `, displayName, avatarURL, did) 122 - return err 123 - } 124 - 125 90 func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) { 126 91 rows, err := s.db.QueryContext(ctx, ` 127 - SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 92 + SELECT did, indexed_at, updated_at 128 93 FROM users ORDER BY updated_at DESC 129 94 `) 130 95 if err != nil { ··· 135 100 var users []*User 136 101 for rows.Next() { 137 102 u := &User{} 138 - if err := rows.Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt); err != nil { 103 + if err := rows.Scan(&u.DID, &u.IndexedAt, &u.UpdatedAt); err != nil { 139 104 return nil, err 140 105 } 141 106 users = append(users, u)
+5 -25
internal/db/user_test.go
··· 7 7 "gotest.tools/v3/assert" 8 8 ) 9 9 10 - func TestUpdateUserProfile_SetsFields(t *testing.T) { 11 - ctx := context.Background() 12 - dbs := setupTestDB(t) 13 - 14 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:profile", "tester") 15 - assert.NilError(t, err) 16 - 17 - err = dbs.Users.UpdateUserProfile(ctx, "did:test:profile", "Display Name", "https://cdn.bsky.app/img/avatar.png") 18 - assert.NilError(t, err) 19 - 20 - u, err := dbs.Users.GetUser(ctx, "did:test:profile") 21 - assert.NilError(t, err) 22 - assert.Equal(t, u.DisplayName.String, "Display Name") 23 - assert.Equal(t, u.AvatarURL.String, "https://cdn.bsky.app/img/avatar.png") 24 - } 25 - 26 - func TestUpdateUserProfile_DoesNotOverwriteWithEmpty(t *testing.T) { 10 + func TestGetUser(t *testing.T) { 27 11 ctx := context.Background() 28 12 dbs := setupTestDB(t) 29 13 30 - _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle, display_name, avatar_url) VALUES (?, ?, ?, ?)`, 31 - "did:test:profile2", "tester2", "Existing Name", "https://old.avatar/url") 14 + u, err := dbs.Users.CreateUser(ctx, "did:test:profile") 32 15 assert.NilError(t, err) 16 + assert.Equal(t, u.DID, "did:test:profile") 33 17 34 - err = dbs.Users.UpdateUserProfile(ctx, "did:test:profile2", "", "") 18 + got, err := dbs.Users.GetUser(ctx, "did:test:profile") 35 19 assert.NilError(t, err) 36 - 37 - u, err := dbs.Users.GetUser(ctx, "did:test:profile2") 38 - assert.NilError(t, err) 39 - assert.Equal(t, u.DisplayName.String, "Existing Name") 40 - assert.Equal(t, u.AvatarURL.String, "https://old.avatar/url") 20 + assert.Equal(t, got.DID, "did:test:profile") 41 21 }
+11 -1
internal/server/annotations_handler.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "fmt" 6 7 "net/http" ··· 50 51 if err != nil { 51 52 s.logger.Warn("failed to list annotations", "error", err, "did", user.DID) 52 53 } 54 + resolveAnnotationHandles(ctx, annotations) 53 55 annotHasMore := len(annotations) > limit 54 56 if annotHasMore { 55 57 annotations = annotations[:limit] ··· 120 122 return 121 123 } 122 124 123 - a.AuthorHandle = user.Handle 125 + a.AuthorHandle = atproto.ResolveProfile(r.Context(), user.DID).Handle 124 126 s.render(w, r, "annotation-card.html", map[string]any{ 125 127 "annotation": a, 126 128 "userDID": user.DID, ··· 165 167 166 168 w.WriteHeader(http.StatusOK) 167 169 } 170 + 171 + func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation) { 172 + for _, a := range annotations { 173 + if a.AuthorDID != "" { 174 + a.AuthorHandle = atproto.ResolveProfile(ctx, a.AuthorDID).Handle 175 + } 176 + } 177 + }
+3 -15
internal/server/auth_handler.go
··· 33 33 http.Error(w, "could not resolve handle", http.StatusInternalServerError) 34 34 return 35 35 } 36 - user, createErr := s.dbs.Users.CreateUser(r.Context(), did, handle, "", "") 36 + user, createErr := s.dbs.Users.CreateUser(r.Context(), did) 37 37 if createErr != nil { 38 38 http.Error(w, createErr.Error(), http.StatusInternalServerError) 39 39 return ··· 67 67 return 68 68 } 69 69 70 - user, err := s.dbs.Users.CreateUser(r.Context(), did, handle, "", "") 70 + user, err := s.dbs.Users.CreateUser(r.Context(), did) 71 71 if err != nil { 72 72 s.logger.Error("failed to create user", "error", err) 73 73 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 87 87 } 88 88 89 89 did := sessData.AccountDID.String() 90 - handle := did 91 - if ident, err := s.oauth.Dir.LookupDID(r.Context(), sessData.AccountDID); err == nil { 92 - handle = ident.Handle.String() 93 - } 94 90 95 91 client := s.pdsClientFromSession(sessData) 96 92 97 - var displayName, avatarURL string 98 - if client != nil { 99 - if dn, avatar, err := client.GetProfile(r.Context(), did); err == nil { 100 - displayName = dn 101 - avatarURL = avatar 102 - } 103 - } 104 - 105 - user, err := s.dbs.Users.CreateUser(r.Context(), did, handle, displayName, avatarURL) 93 + user, err := s.dbs.Users.CreateUser(r.Context(), did) 106 94 if err != nil { 107 95 s.logger.Error("failed to create user", "error", err) 108 96 http.Error(w, err.Error(), http.StatusInternalServerError)
+12
internal/server/dashboard_handler.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "net/http" 5 6 "time" 6 7 8 + "pkg.rbrt.fr/glean/internal/atproto" 7 9 "pkg.rbrt.fr/glean/internal/cluster" 8 10 ) 9 11 ··· 41 43 if err != nil { 42 44 s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID) 43 45 } 46 + resolvePeopleHandles(ctx, peopleRecs) 44 47 45 48 feedRecs, err := s.engine.GetFeedRecommendations(ctx, user.DID, 5) 46 49 if err != nil { ··· 110 113 111 114 w.WriteHeader(http.StatusOK) 112 115 } 116 + 117 + func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommendation) { 118 + for _, p := range people { 119 + prof := atproto.ResolveProfile(ctx, p.DID) 120 + p.Handle = prof.Handle 121 + p.DisplayName = prof.DisplayName 122 + p.AvatarURL = prof.AvatarURL 123 + } 124 + }
+1
internal/server/feeds_handler.go
··· 43 43 if err != nil { 44 44 s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID) 45 45 } 46 + resolvePeopleHandles(ctx, peopleRecs) 46 47 47 48 if len(feedRecs) > 0 { 48 49 impressions := make([]cluster.Impression, len(feedRecs))
+11 -21
internal/server/profile_handler.go
··· 17 17 if strings.HasPrefix(param, "did:") { 18 18 did = param 19 19 } else { 20 - profileUser, err := s.dbs.Users.GetUserByHandle(ctx, param) 21 - if err == nil { 22 - did = profileUser.DID 23 - } else { 24 - resolved, err := atproto.ResolveHandle(ctx, param) 25 - if err != nil { 26 - s.logger.Warn("failed to resolve handle", "error", err, "handle", param) 27 - http.Error(w, "handle not found", http.StatusNotFound) 28 - return 29 - } 30 - did = resolved 20 + resolved, err := atproto.ResolveHandle(ctx, param) 21 + if err != nil { 22 + s.logger.Warn("failed to resolve handle", "error", err, "handle", param) 23 + http.Error(w, "handle not found", http.StatusNotFound) 24 + return 31 25 } 26 + did = resolved 32 27 } 33 28 34 29 profileUser, err := s.dbs.Users.GetUser(ctx, did) ··· 38 33 return 39 34 } 40 35 41 - if !profileUser.AvatarURL.Valid || profileUser.AvatarURL.String == "" { 42 - _, displayName, avatarURL, err := atproto.FetchProfile(ctx, did) 43 - if err == nil && avatarURL != "" { 44 - if err := s.dbs.Users.UpdateUserProfile(ctx, did, displayName, avatarURL); err != nil { 45 - s.logger.Warn("failed to update user profile", "error", err, "did", did) 46 - } 47 - profileUser.DisplayName = nullString(displayName) 48 - profileUser.AvatarURL = nullString(avatarURL) 49 - } 50 - } 36 + p := atproto.ResolveProfile(ctx, did) 37 + profileUser.Handle = p.Handle 38 + profileUser.DisplayName = p.DisplayName 39 + profileUser.AvatarURL = p.AvatarURL 51 40 52 41 subs, err := s.dbs.Articles.ListSubscriptions(ctx, did, "", 50, 0) 53 42 if err != nil { ··· 58 47 if err != nil { 59 48 s.logger.Warn("failed to list annotations", "error", err, "did", did) 60 49 } 50 + resolveAnnotationHandles(ctx, annotations) 61 51 62 52 subCount, err := s.dbs.Articles.GetSubscriptionCount(ctx, did) 63 53 if err != nil {
+1 -26
internal/server/server.go
··· 12 12 "strconv" 13 13 "strings" 14 14 "sync" 15 - 16 - "golang.org/x/sync/errgroup" 17 15 "time" 18 16 19 17 "github.com/go-chi/chi/v5" ··· 490 488 defer func() { <-sem }() 491 489 defer wg.Done() 492 490 493 - g, gCtx := errgroup.WithContext(ctx) 494 - 495 - var handle, displayName, avatarURL string 496 - g.Go(func() error { 497 - if ident, err := atproto.ResolveIdentity(gCtx, did); err == nil { 498 - handle = ident.Handle.String() 499 - } 500 - return nil 501 - }) 502 - g.Go(func() error { 503 - if h, dn, avatar, err := atproto.FetchProfile(gCtx, did); err == nil { 504 - handle = h 505 - displayName = dn 506 - avatarURL = avatar 507 - } 508 - return nil 509 - }) 510 - _ = g.Wait() 511 - 512 - if _, err := s.dbs.Users.CreateUser(ctx, did, handle, displayName, avatarURL); err != nil { 491 + if _, err := s.dbs.Users.CreateUser(ctx, did); err != nil { 513 492 s.logger.Error("failed to create user during backfill", "error", err, "did", did) 514 493 return 515 494 } ··· 561 540 if err := sync.Run(ctx, u.DID); err != nil { 562 541 metrics.SyncErrors.Inc() 563 542 s.logger.Error("periodic sync failed", "error", err, "did", u.DID) 564 - } 565 - 566 - if dn, avatar, err := client.GetProfile(ctx, u.DID); err == nil { 567 - _ = s.dbs.Users.UpdateUserProfile(ctx, u.DID, dn, avatar) 568 543 } 569 544 570 545 metrics.SyncRuns.Inc()
+5
internal/server/session.go
··· 9 9 "net/http" 10 10 "os" 11 11 12 + "pkg.rbrt.fr/glean/internal/atproto" 12 13 "pkg.rbrt.fr/glean/internal/db" 13 14 ) 14 15 ··· 39 40 return nil 40 41 } 41 42 43 + p := atproto.ResolveProfile(r.Context(), user.DID) 44 + user.Handle = p.Handle 45 + user.DisplayName = p.DisplayName 46 + user.AvatarURL = p.AvatarURL 42 47 return user 43 48 } 44 49
+2 -2
internal/tmpl/base.html
··· 96 96 {{if .User}} 97 97 <div class="flex items-center gap-2"> 98 98 <a href="/profile/{{.User.DID}}" class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-md hover:bg-spot-hover-50 transition"> 99 - {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full shrink-0">{{end}} 99 + {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full shrink-0">{{end}} 100 100 <span class="text-sm font-medium truncate text-spot-text">@{{.User.Handle}}</span> 101 101 </a> 102 102 <form method="POST" action="/auth/logout" class="shrink-0"> ··· 142 142 {{template "logo-text"}} 143 143 {{if .User}} 144 144 <a href="/profile/{{.User.DID}}" class="flex items-center gap-2"> 145 - {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}} 145 + {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full">{{end}} 146 146 <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span> 147 147 </a> 148 148 {{else}}
+1 -1
internal/tmpl/partials/profile-card.html
··· 2 2 <div 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/{{.Handle}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a> 5 + <a href="/profile/{{.DID}}" class="font-bold text-spot-text hover:text-spot-green transition">@{{.Handle}}</a> 6 6 {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}} 7 7 </div> 8 8 <span class="text-xs text-spot-secondary">{{.CommonFeeds}} shared</span>
+2 -2
internal/tmpl/profile.html
··· 2 2 <div class="max-w-2xl mx-auto"> 3 3 <div class="bg-spot-surface rounded-xl p-6 mb-6"> 4 4 <div class="flex items-center gap-4"> 5 - {{if .ProfileUser.AvatarURL.Valid}}<img src="{{.ProfileUser.AvatarURL.String}}" class="w-20 h-20 rounded-full ring-2 ring-spot-divider">{{else}}<div class="w-20 h-20 rounded-full bg-spot-hover flex items-center justify-center text-spot-muted"><svg class="w-10 h-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></div>{{end}} 5 + {{if .ProfileUser.AvatarURL}}<img src="{{.ProfileUser.AvatarURL}}" class="w-20 h-20 rounded-full ring-2 ring-spot-divider">{{else}}<div class="w-20 h-20 rounded-full bg-spot-hover flex items-center justify-center text-spot-muted"><svg class="w-10 h-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></div>{{end}} 6 6 <div class="min-w-0 flex-1"> 7 7 <h1 class="text-2xl font-bold text-spot-text truncate"> 8 - {{if .ProfileUser.DisplayName.Valid}}{{.ProfileUser.DisplayName.String}}{{end}} 8 + {{if .ProfileUser.DisplayName}}{{.ProfileUser.DisplayName}}{{end}} 9 9 </h1> 10 10 <p class="flex items-center gap-1.5 text-spot-secondary">@{{.ProfileUser.Handle}}<a href="https://bsky.app/profile/{{.ProfileUser.Handle}}" target="_blank" rel="noopener" class="text-spot-secondary hover:text-spot-green transition"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">{{template "icon-bluesky"}}</svg></a></p> 11 11 <div class="flex gap-4 mt-3">