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 next article navigation

+123 -9
+56
internal/db/article.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "strings" 7 8 "time" 8 9 "unicode" ··· 38 39 IsRead sql.NullBool 39 40 LikeCount int 40 41 HasLiked bool 42 + // NavSuffix holds the query string appended to article detail links to preserve 43 + // listing context (feed scope, liked) for next-article navigation. 44 + NavSuffix string 41 45 } 42 46 43 47 type ReadState struct { ··· 403 407 WHERE a.fetched_at > ? 404 408 `, userDID, since).Scan(&count) 405 409 return count, err 410 + } 411 + 412 + func (s *ArticleStore) GetNextArticleID(ctx context.Context, userDID string, articleID int64, feedURL string, liked bool) (*int64, error) { 413 + var fromParts []string 414 + var whereParts []string 415 + 416 + fromParts = append(fromParts, "articles.articles a") 417 + 418 + if feedURL != "" { 419 + whereParts = append(whereParts, "a.feed_url = ?") 420 + } else { 421 + fromParts = append(fromParts, "JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?") 422 + } 423 + 424 + if liked { 425 + fromParts = append(fromParts, "JOIN articles.likes l ON l.author_did = ? AND l.article_url = a.url") 426 + } 427 + 428 + whereParts = append(whereParts, "a.id != ?") 429 + 430 + fromClause := strings.Join(fromParts, " ") 431 + whereClause := strings.Join(whereParts, " AND ") 432 + 433 + query := fmt.Sprintf(` 434 + WITH cur AS (SELECT published FROM articles.articles WHERE id = ?) 435 + SELECT a.id FROM %s, cur 436 + WHERE %s AND ( 437 + (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) > (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) 438 + OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published < cur.published 439 + OR (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) = (CASE WHEN cur.published > 'now' THEN 1 ELSE 0 END) AND a.published = cur.published AND a.id > ? 440 + ) 441 + ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END) ASC, a.published DESC, a.id ASC 442 + LIMIT 1 443 + `, fromClause, whereClause) 444 + 445 + var args []any 446 + args = append(args, articleID) 447 + if feedURL != "" { 448 + args = append(args, feedURL) 449 + } else { 450 + args = append(args, userDID) 451 + } 452 + if liked { 453 + args = append(args, userDID) 454 + } 455 + args = append(args, articleID, articleID) 456 + 457 + var next sql.NullInt64 458 + if err := s.db.QueryRowContext(ctx, query, args...).Scan(&next); err != nil { 459 + return nil, nil 460 + } 461 + return &next.Int64, nil 406 462 } 407 463 408 464 func escapeFTS5(query string) string {
+5
internal/server/annotations_handler.go
··· 48 48 likedPage.NextPage = likedPage.Page + 1 49 49 } 50 50 51 + navSuffix := buildNavSuffix("", true) 52 + for _, a := range articles { 53 + a.NavSuffix = navSuffix 54 + } 55 + 51 56 annotations, err := s.dbs.Articles.ListAnnotations(ctx, "", "", user.DID, limit+1, annotPage.Offset()) 52 57 if err != nil { 53 58 s.logger.Warn("failed to list annotations", "error", err, "did", user.DID)
+30
internal/server/articles_handler.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "net/http" 8 + "net/url" 8 9 "strconv" 9 10 "time" 10 11 ··· 77 78 page = page.Paginate(totalFetched) 78 79 if page.HasNext { 79 80 articles = articles[:page.PageSize] 81 + } 82 + 83 + navSuffix := buildNavSuffix(feedURL, false) 84 + for _, a := range articles { 85 + a.NavSuffix = navSuffix 80 86 } 81 87 82 88 data := map[string]any{ ··· 195 201 s.logger.Warn("failed to get feed", "error", err, "feed", article.FeedURL) 196 202 } 197 203 204 + fromFeedURL := r.URL.Query().Get("from_feed") 205 + navLiked := r.URL.Query().Get("liked") == "1" 206 + 207 + nextID, err := s.dbs.Articles.GetNextArticleID(ctx, user.DID, id, fromFeedURL, navLiked) 208 + if err != nil { 209 + s.logger.Warn("failed to get next article", "error", err, "id", id) 210 + } 211 + 198 212 s.render(w, r, "article_detail.html", map[string]any{ 199 213 "User": user, 200 214 "CurrentUserDID": user.DID, ··· 204 218 "LikeCount": likeCount, 205 219 "HasLiked": liked, 206 220 "Annotations": annotations, 221 + "NextID": nextID, 222 + "NextSuffix": buildNavSuffix(fromFeedURL, navLiked), 207 223 }) 208 224 } 209 225 ··· 404 420 _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned) 405 421 s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned)) 406 422 } 423 + 424 + func buildNavSuffix(feedURL string, liked bool) string { 425 + v := url.Values{} 426 + if feedURL != "" { 427 + v.Set("from_feed", feedURL) 428 + } 429 + if liked { 430 + v.Set("liked", "1") 431 + } 432 + if len(v) == 0 { 433 + return "" 434 + } 435 + return "?" + v.Encode() 436 + }
+31 -8
internal/tmpl/article_detail.html
··· 1 1 {{define "article_detail.html"}} 2 2 <div class="max-w-3xl mx-auto"> 3 - <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mb-6 inline-flex items-center gap-1.5 transition"> 4 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg> 5 - Back 6 - </a> 3 + <div class="flex items-center justify-between mb-6"> 4 + <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition"> 5 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg> 6 + Back 7 + </a> 8 + {{if .NextID}} 9 + <a href="/articles/{{.NextID}}{{.NextSuffix}}" id="next-link" 10 + class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition"> 11 + Next 12 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg> 13 + </a> 14 + {{end}} 15 + </div> 7 16 8 17 <article> 9 18 <h1 class="text-2xl font-bold leading-tight"> ··· 138 147 </div> 139 148 </section> 140 149 141 - <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text mt-8 inline-flex items-center gap-1.5 transition"> 142 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg> 143 - Back 144 - </a> 150 + <div class="flex items-center justify-between mt-8"> 151 + <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition"> 152 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg> 153 + Back 154 + </a> 155 + {{if .NextID}} 156 + <a href="/articles/{{.NextID}}{{.NextSuffix}}" 157 + class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition"> 158 + Next 159 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg> 160 + </a> 161 + {{end}} 162 + </div> 145 163 </div> 146 164 147 165 <script> ··· 259 277 } else if (e.key === 'o') { 260 278 var origLink = document.querySelector('a[target="_blank"]'); 261 279 if (origLink && origLink.href) window.open(origLink.href, '_blank'); 280 + } else if (e.key === 'ArrowRight') { 281 + var nextLink = document.getElementById('next-link'); 282 + if (nextLink) nextLink.click(); 283 + } else if (e.key === 'ArrowLeft') { 284 + history.back(); 262 285 } 263 286 }); 264 287 })();
+1 -1
internal/tmpl/partials/article-card.html
··· 4 4 <div class="min-w-0 flex-1"> 5 5 <div class="flex items-center gap-2"> 6 6 {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0"></span>{{end}} 7 - <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-green transition text-lg leading-tight">{{.Title}}</a> 7 + <a href="/articles/{{.ID}}{{.NavSuffix}}" 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 10 {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-4 h-4"}}