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.

Add favicon support to feed data and UI

+66 -36
+21 -20
internal/db/article.go
··· 7 7 ) 8 8 9 9 type Article struct { 10 - ID int64 11 - FeedURL string 12 - FeedTitle string 13 - GUID string 14 - Title string 15 - URL sql.NullString 16 - Author sql.NullString 17 - Summary sql.NullString 18 - Content sql.NullString 19 - FullContent sql.NullString 20 - Published sql.NullTime 21 - Updated sql.NullTime 22 - FetchedAt sql.NullTime 23 - IsRead sql.NullBool 10 + ID int64 11 + FeedURL string 12 + FeedTitle string 13 + FeedFaviconURL sql.NullString 14 + GUID string 15 + Title string 16 + URL sql.NullString 17 + Author sql.NullString 18 + Summary sql.NullString 19 + Content sql.NullString 20 + FullContent sql.NullString 21 + Published sql.NullTime 22 + Updated sql.NullTime 23 + FetchedAt sql.NullTime 24 + IsRead sql.NullBool 24 25 } 25 26 26 27 type ReadState struct { ··· 62 63 63 64 func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 64 65 query := ` 65 - SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, 66 + SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, 66 67 a.published, a.updated, a.fetched_at, 67 68 COALESCE(r.is_read, 0) 68 69 FROM articles a ··· 88 89 var articles []*Article 89 90 for rows.Next() { 90 91 a := &Article{} 91 - if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, 92 + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author, 92 93 &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, 93 94 &a.IsRead); err != nil { 94 95 return nil, err ··· 100 101 101 102 func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 102 103 query := ` 103 - SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, 104 + SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, 104 105 a.published, a.updated, a.fetched_at, 105 106 COALESCE(r.is_read, 0) 106 107 FROM articles a ··· 127 128 var articles []*Article 128 129 for rows.Next() { 129 130 a := &Article{} 130 - if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, 131 + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author, 131 132 &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, 132 133 &a.IsRead); err != nil { 133 134 return nil, err ··· 139 140 140 141 func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 141 142 query := ` 142 - SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, 143 + SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, 143 144 a.published, a.updated, a.fetched_at, 144 145 COALESCE(r.is_read, 0) 145 146 FROM articles a ··· 166 167 var articles []*Article 167 168 for rows.Next() { 168 169 a := &Article{} 169 - if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, 170 + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author, 170 171 &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, 171 172 &a.IsRead); err != nil { 172 173 return nil, err
+4 -2
internal/db/cluster.go
··· 205 205 206 206 func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { 207 207 rows, err := db.QueryContext(ctx, ` 208 - SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count 208 + SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count, f.favicon_url 209 209 FROM user_feed_recommendations r 210 210 JOIN feeds f ON f.feed_url = r.feed_url 211 211 WHERE r.user_did = ? ··· 222 222 var feedURL string 223 223 var score float64 224 224 var title, siteURL, description sql.NullString 225 + var faviconURL sql.NullString 225 226 var subCount int 226 - if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount); err != nil { 227 + if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount, &faviconURL); err != nil { 227 228 return nil, err 228 229 } 229 230 results = append(results, map[string]any{ ··· 233 234 "site_url": siteURL, 234 235 "description": description, 235 236 "subscriber_count": subCount, 237 + "favicon_url": faviconURL, 236 238 }) 237 239 } 238 240 return results, rows.Err()
+3 -2
internal/db/feed.go
··· 35 35 UnreadCount int 36 36 URI sql.NullString 37 37 CID sql.NullString 38 + FaviconURL sql.NullString 38 39 } 39 40 40 41 func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error { ··· 228 229 229 230 func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { 230 231 query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 231 - s.uri, s.cid 232 + s.uri, s.cid, f.favicon_url 232 233 FROM subscriptions s 233 234 LEFT JOIN feeds f ON s.feed_url = f.feed_url 234 235 WHERE s.user_did = ?` ··· 250 251 var subs []*Subscription 251 252 for rows.Next() { 252 253 s := &Subscription{} 253 - if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID); err != nil { 254 + if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID, &s.FaviconURL); err != nil { 254 255 return nil, err 255 256 } 256 257 subs = append(subs, s)
+5 -2
internal/db/social.go
··· 201 201 Summary string 202 202 FeedURL string 203 203 FeedTitle string 204 + FaviconURL string 204 205 LikeCount int 205 206 AnnotationCount int 206 207 } ··· 209 210 rows, err := db.QueryContext(ctx, fmt.Sprintf(` 210 211 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), 211 212 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), 213 + COALESCE(f.favicon_url, ''), 212 214 COUNT(DISTINCT l.id) AS like_count, 213 215 COUNT(DISTINCT a.id) AS annotation_count 214 216 FROM likes l ··· 235 237 for rows.Next() { 236 238 item := &TrendingItem{} 237 239 if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author, 238 - &item.Summary, &item.FeedURL, &item.FeedTitle, 240 + &item.Summary, &item.FeedURL, &item.FeedTitle, &item.FaviconURL, 239 241 &item.LikeCount, &item.AnnotationCount); err != nil { 240 242 return nil, err 241 243 } ··· 248 250 rows, err := db.QueryContext(ctx, fmt.Sprintf(` 249 251 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), 250 252 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), 253 + COALESCE(f.favicon_url, ''), 251 254 COUNT(DISTINCT l.id) AS like_count, 252 255 COUNT(DISTINCT a.id) AS annotation_count 253 256 FROM likes l ··· 268 271 for rows.Next() { 269 272 item := &TrendingItem{} 270 273 if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author, 271 - &item.Summary, &item.FeedURL, &item.FeedTitle, 274 + &item.Summary, &item.FeedURL, &item.FeedTitle, &item.FaviconURL, 272 275 &item.LikeCount, &item.AnnotationCount); err != nil { 273 276 return nil, err 274 277 }
+4
internal/db/store.go
··· 62 62 func (a *FeedStoreAdapter) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error { 63 63 return a.db.MarkFeedFetchError(ctx, feedURL, lastError) 64 64 } 65 + 66 + func (a *FeedStoreAdapter) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { 67 + return a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL) 68 + }
+10
internal/feed/fetcher.go
··· 72 72 UpsertArticle(ctx context.Context, article *Article) (int64, error) 73 73 MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error 74 74 MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error 75 + UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error 75 76 } 76 77 77 78 type fetchCall struct { ··· 167 168 168 169 if err := s.store.MarkFeedFetched(ctx, feed.URL, newEtag, newLastModified); err != nil { 169 170 s.logger.Error("failed to update feed fetch result", "error", err, "feed", feed.URL) 171 + } 172 + 173 + if feed.SiteURL != "" { 174 + go func() { 175 + discResult, err := Discover(context.Background(), feed.SiteURL) 176 + if err == nil && discResult.Favicon != "" { 177 + _ = s.store.UpdateFeedFavicon(context.Background(), feed.URL, discResult.Favicon) 178 + } 179 + }() 170 180 } 171 181 }
+8 -5
internal/tmpl/feeds.html
··· 54 54 <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider"> 55 55 {{range .Subscriptions}} 56 56 <div class="feed-item px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-xl"> 57 - <div class="min-w-0 flex-1"> 58 - <div class="flex items-center gap-2"> 59 - <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> 60 - {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} 57 + <div class="min-w-0 flex-1 flex items-center gap-3"> 58 + {{if .FaviconURL.Valid}}<img src="{{.FaviconURL.String}}" class="w-5 h-5 rounded shrink-0" loading="lazy">{{else}}<span class="shrink-0 w-5 h-5 flex items-center justify-center text-spot-muted">{{template "icon-globe"}}</span>{{end}} 59 + <div class="min-w-0 flex-1"> 60 + <div class="flex items-center gap-2"> 61 + <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> 62 + {{if .Category.Valid}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-full shrink-0">{{.Category.String}}</span>{{end}} 63 + </div> 64 + <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> 61 65 </div> 62 - <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div> 63 66 </div> 64 67 <div class="flex items-center gap-3"> 65 68 {{if .UnreadCount}}<span class="text-xs bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}}
+1
internal/tmpl/partials/article-card.html
··· 7 7 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a> 8 8 </div> 9 9 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> 10 + {{if .FeedFaviconURL.Valid}}<img src="{{.FeedFaviconURL.String}}" class="w-4 h-4 rounded shrink-0" loading="lazy">{{end}} 10 11 {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} 11 12 {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} 12 13 <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
+1
internal/tmpl/partials/article-list.html
··· 5 5 <div class="min-w-0 flex-1"> 6 6 <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a> 7 7 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> 8 + {{if .FeedFaviconURL.Valid}}<img src="{{.FeedFaviconURL.String}}" class="w-4 h-4 rounded shrink-0" loading="lazy">{{end}} 8 9 {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} 9 10 {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} 10 11 <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
+8 -5
internal/tmpl/partials/recommendation-card.html
··· 1 1 {{define "recommendation-card.html"}} 2 2 <div class="bg-spot-surface rounded-xl p-3 hover:bg-spot-hover-50 transition"> 3 - <div class="flex items-center justify-between"> 4 - <div class="min-w-0"> 5 - <div class="font-bold text-sm text-spot-text">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div> 6 - {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}} 3 + <div class="flex items-center justify-between gap-2"> 4 + <div class="min-w-0 flex items-center gap-2"> 5 + {{if .favicon_url}}{{if .favicon_url.Valid}}<img src="{{.favicon_url.String}}" class="w-4 h-4 rounded shrink-0" loading="lazy">{{end}}{{end}} 6 + <div class="min-w-0"> 7 + <div class="font-bold text-sm text-spot-text truncate">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div> 8 + {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}} 9 + </div> 7 10 </div> 8 - <span class="text-xs text-spot-secondary shrink-0 ml-2">{{.subscriber_count}} subs</span> 11 + <span class="text-xs text-spot-secondary shrink-0">{{.subscriber_count}} subs</span> 9 12 </div> 10 13 </div> 11 14 {{end}}
+1
internal/tmpl/trending.html
··· 19 19 <div class="min-w-0 flex-1"> 20 20 <a href="/articles/{{$t.ArticleID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{$t.Title}}</a> 21 21 <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> 22 + {{if $t.FaviconURL}}<img src="{{$t.FaviconURL}}" class="w-4 h-4 rounded shrink-0" loading="lazy">{{end}} 22 23 {{if $t.Author}}<span>{{$t.Author}}</span>{{end}} 23 24 <span class="text-spot-muted">{{if $t.FeedTitle}}{{$t.FeedTitle}}{{else}}{{$t.FeedURL}}{{end}}</span> 24 25 </div>