Implement async feed refresh with polling and category filteringUnverified
9f09d5b parent: 6be8ad9 modified
internal/server/feeds_handler.go +23 -14 | @@ -338,11 +338,12 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) { | ||
| 338 | 338 | |
| 339 | 339 | func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { |
| 340 | 340 | user := currentUser(r) |
| 341 | - subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 341 | + category := r.URL.Query().Get("category") | |
| 342 | + subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, category, 100, 0) | |
| 342 | 343 | if err != nil { |
| 343 | 344 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) |
| 344 | 345 | } |
| 345 | - s.render(w, r, "feeds.html", map[string]any{ | |
| 346 | + s.render(w, r, "feed-list.html", map[string]any{ | |
| 346 | 347 | "User": user, |
| 347 | 348 | "Subscriptions": subs, |
| 348 | 349 | }) |
| @@ -350,11 +351,28 @@ func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | ||
| 350 | 351 | |
| 351 | 352 | func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { |
| 352 | 353 | user := currentUser(r) |
| 354 | + ctx := r.Context() | |
| 355 | + | |
| 356 | + go s.refreshUserFeeds(context.WithoutCancel(ctx), user.DID) | |
| 353 | 357 | |
| 354 | - subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 358 | + category := r.URL.Query().Get("category") | |
| 359 | + subs, err := s.dbs.Articles.ListSubscriptions(ctx, user.DID, category, 100, 0) | |
| 355 | 360 | if err != nil { |
| 356 | 361 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) |
| 357 | 362 | } |
| 363 | + s.render(w, r, "feed-list.html", map[string]any{ | |
| 364 | + "User": user, | |
| 365 | + "Subscriptions": subs, | |
| 366 | + }) | |
| 367 | +} | |
| 368 | + | |
| 369 | +func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) { | |
| 370 | + subs, err := s.dbs.Articles.ListSubscriptions(ctx, userDID, "", 100, 0) | |
| 371 | + if err != nil { | |
| 372 | + s.logger.Warn("failed to list subscriptions for refresh", "error", err, "did", userDID) | |
| 373 | + return | |
| 374 | + } | |
| 375 | + | |
| 358 | 376 | seen := make(map[string]bool) |
| 359 | 377 | for _, sub := range subs { |
| 360 | 378 | if seen[sub.FeedURL] { |
| @@ -362,7 +380,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 362 | 380 | } |
| 363 | 381 | seen[sub.FeedURL] = true |
| 364 | 382 | |
| 365 | - f, err := s.dbs.Articles.GetFeed(r.Context(), sub.FeedURL) | |
| 383 | + f, err := s.dbs.Articles.GetFeed(ctx, sub.FeedURL) | |
| 366 | 384 | if err != nil { |
| 367 | 385 | s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL) |
| 368 | 386 | continue |
| @@ -376,17 +394,8 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 376 | 394 | ETag: f.Etag.String, |
| 377 | 395 | LastModified: f.LastModified.String, |
| 378 | 396 | } |
| 379 | - s.scheduler.FetchFeed(r.Context(), ff) | |
| 380 | - } | |
| 381 | - | |
| 382 | - subs, err = s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 383 | - if err != nil { | |
| 384 | - s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) | |
| 397 | + s.scheduler.FetchFeed(ctx, ff) | |
| 385 | 398 | } |
| 386 | - s.render(w, r, "feed-list.html", map[string]any{ | |
| 387 | - "User": user, | |
| 388 | - "Subscriptions": subs, | |
| 389 | - }) | |
| 390 | 399 | } |
| 391 | 400 | |
| 392 | 401 | func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { |
| @@ -338,11 +338,12 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) { | |||
| 338 | 338 | ||
| 339 | func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | 339 | func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { |
| 340 | user := currentUser(r) | 340 | user := currentUser(r) |
| 341 | - subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | 341 | + category := r.URL.Query().Get("category") |
| 342 | + subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, category, 100, 0) | ||
| 342 | if err != nil { | 343 | if err != nil { |
| 343 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) | 344 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) |
| 344 | } | 345 | } |
| 345 | - s.render(w, r, "feeds.html", map[string]any{ | 346 | + s.render(w, r, "feed-list.html", map[string]any{ |
| 346 | "User": user, | 347 | "User": user, |
| 347 | "Subscriptions": subs, | 348 | "Subscriptions": subs, |
| 348 | }) | 349 | }) |
| @@ -350,11 +351,28 @@ func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | |||
| 350 | 351 | ||
| 351 | func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | 352 | func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { |
| 352 | user := currentUser(r) | 353 | user := currentUser(r) |
| 354 | + ctx := r.Context() | ||
| 355 | + | ||
| 356 | + go s.refreshUserFeeds(context.WithoutCancel(ctx), user.DID) | ||
| 353 | 357 | ||
| 354 | - subs, err := s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | 358 | + category := r.URL.Query().Get("category") |
| 359 | + subs, err := s.dbs.Articles.ListSubscriptions(ctx, user.DID, category, 100, 0) | ||
| 355 | if err != nil { | 360 | if err != nil { |
| 356 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) | 361 | s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) |
| 357 | } | 362 | } |
| 363 | + s.render(w, r, "feed-list.html", map[string]any{ | ||
| 364 | + "User": user, | ||
| 365 | + "Subscriptions": subs, | ||
| 366 | + }) | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) { | ||
| 370 | + subs, err := s.dbs.Articles.ListSubscriptions(ctx, userDID, "", 100, 0) | ||
| 371 | + if err != nil { | ||
| 372 | + s.logger.Warn("failed to list subscriptions for refresh", "error", err, "did", userDID) | ||
| 373 | + return | ||
| 374 | + } | ||
| 375 | + | ||
| 358 | seen := make(map[string]bool) | 376 | seen := make(map[string]bool) |
| 359 | for _, sub := range subs { | 377 | for _, sub := range subs { |
| 360 | if seen[sub.FeedURL] { | 378 | if seen[sub.FeedURL] { |
| @@ -362,7 +380,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 362 | } | 380 | } |
| 363 | seen[sub.FeedURL] = true | 381 | seen[sub.FeedURL] = true |
| 364 | 382 | ||
| 365 | - f, err := s.dbs.Articles.GetFeed(r.Context(), sub.FeedURL) | 383 | + f, err := s.dbs.Articles.GetFeed(ctx, sub.FeedURL) |
| 366 | if err != nil { | 384 | if err != nil { |
| 367 | s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL) | 385 | s.logger.Warn("failed to get feed", "error", err, "feed", sub.FeedURL) |
| 368 | continue | 386 | continue |
| @@ -376,17 +394,8 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 376 | ETag: f.Etag.String, | 394 | ETag: f.Etag.String, |
| 377 | LastModified: f.LastModified.String, | 395 | LastModified: f.LastModified.String, |
| 378 | } | 396 | } |
| 379 | - s.scheduler.FetchFeed(r.Context(), ff) | 397 | + s.scheduler.FetchFeed(ctx, ff) |
| 380 | - } | ||
| 381 | - | ||
| 382 | - subs, err = s.dbs.Articles.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 383 | - if err != nil { | ||
| 384 | - s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID) | ||
| 385 | } | 398 | } |
| 386 | - s.render(w, r, "feed-list.html", map[string]any{ | ||
| 387 | - "User": user, | ||
| 388 | - "Subscriptions": subs, | ||
| 389 | - }) | ||
| 390 | } | 399 | } |
| 391 | 400 | ||
| 392 | func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { | 401 | func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { |
modified
internal/tmpl/feeds.html +10 -4 | @@ -2,14 +2,20 @@ | ||
| 2 | 2 | <div class="flex items-center justify-between gap-3 mb-2 flex-wrap"> |
| 3 | 3 | <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1> |
| 4 | 4 | <div class="flex items-center gap-3"> |
| 5 | - <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" | |
| 6 | - hx-indicator="#refresh-indicator" | |
| 7 | - class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition"> | |
| 5 | + <button id="refresh-btn" | |
| 6 | + hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" | |
| 7 | + hx-on::before-request="gleanRefreshStart()" | |
| 8 | + hx-on::after-request="gleanRefreshPoll()" | |
| 9 | + class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition disabled:opacity-50"> | |
| 8 | 10 | Refresh feeds |
| 9 | 11 | </button> |
| 10 | - <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span> | |
| 12 | + <span id="refresh-indicator" class="text-sm text-spot-secondary" style="display:none">Refreshing feeds...</span> | |
| 11 | 13 | </div> |
| 12 | 14 | </div> |
| 15 | +<script> | |
| 16 | +function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true} | |
| 17 | +function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)} | |
| 18 | +</script> | |
| 13 | 19 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS and Atom subscriptions.</p> |
| 14 | 20 | |
| 15 | 21 | {{template "dead-feeds.html" (dict "DeadFeeds" .DeadFeeds "CSRFToken" .CSRFToken)}} |
| @@ -2,14 +2,20 @@ | |||
| 2 | <div class="flex items-center justify-between gap-3 mb-2 flex-wrap"> | 2 | <div class="flex items-center justify-between gap-3 mb-2 flex-wrap"> |
| 3 | <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1> | 3 | <h1 class="text-2xl font-bold text-spot-text">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1> |
| 4 | <div class="flex items-center gap-3"> | 4 | <div class="flex items-center gap-3"> |
| 5 | - <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" | 5 | + <button id="refresh-btn" |
| 6 | - hx-indicator="#refresh-indicator" | 6 | + hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" |
| 7 | - class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition"> | 7 | + hx-on::before-request="gleanRefreshStart()" |
| 8 | + hx-on::after-request="gleanRefreshPoll()" | ||
| 9 | + class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition disabled:opacity-50"> | ||
| 8 | Refresh feeds | 10 | Refresh feeds |
| 9 | </button> | 11 | </button> |
| 10 | - <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span> | 12 | + <span id="refresh-indicator" class="text-sm text-spot-secondary" style="display:none">Refreshing feeds...</span> |
| 11 | </div> | 13 | </div> |
| 12 | </div> | 14 | </div> |
| 15 | +<script> | ||
| 16 | +function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true} | ||
| 17 | +function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)} | ||
| 18 | +</script> | ||
| 13 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS and Atom subscriptions.</p> | 19 | <p class="text-sm text-spot-secondary mb-6">Manage your RSS and Atom subscriptions.</p> |
| 14 | 20 | ||
| 15 | {{template "dead-feeds.html" (dict "DeadFeeds" .DeadFeeds "CSRFToken" .CSRFToken)}} | 21 | {{template "dead-feeds.html" (dict "DeadFeeds" .DeadFeeds "CSRFToken" .CSRFToken)}} |