Enable adding toasts in full page reloads (and HTMX where it's hx-boost or target = body)

- use toasts to display non-fatal scraping errors for Tweets
This commit is contained in:
Alessio 2024-08-18 16:36:22 -07:00
parent 91f722b7fa
commit ee2b287fd9
5 changed files with 45 additions and 8 deletions

View File

@ -50,13 +50,16 @@ func (app *Application) ensure_tweet(id scraper.TweetID, is_forced bool, is_conv
if is_needing_scrape && !app.IsScrapingDisabled { if is_needing_scrape && !app.IsScrapingDisabled {
trove, err := scraper.GetTweetFullAPIV2(id, 50) // TODO: parameterizable trove, err := scraper.GetTweetFullAPIV2(id, 50) // TODO: parameterizable
// Save the trove unless there was an unrecoverable error
if err == nil || errors.Is(err, scraper.END_OF_FEED) || errors.Is(err, scraper.ErrRateLimited) { if err == nil || errors.Is(err, scraper.END_OF_FEED) || errors.Is(err, scraper.ErrRateLimited) {
app.Profile.SaveTweetTrove(trove, false) app.Profile.SaveTweetTrove(trove, false)
go app.Profile.SaveTweetTrove(trove, true) // Download the content in the background go app.Profile.SaveTweetTrove(trove, true) // Download the content in the background
_, is_available = trove.Tweets[id] _, is_available = trove.Tweets[id]
} else { }
app.ErrorLog.Print(err)
// TODO: show error in UI if err != nil && !errors.Is(err, scraper.END_OF_FEED) {
return scraper.Tweet{}, fmt.Errorf("scraper error: %w", err)
} }
} else if is_needing_scrape { } else if is_needing_scrape {
app.InfoLog.Printf("Would have scraped Tweet: %d", id) app.InfoLog.Printf("Would have scraped Tweet: %d", id)
@ -115,10 +118,31 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
is_conversation_required := len(parts) <= 2 || (parts[2] != "like" && parts[2] != "unlike") is_conversation_required := len(parts) <= 2 || (parts[2] != "like" && parts[2] != "unlike")
tweet, err := app.ensure_tweet(tweet_id, is_scrape_required, is_conversation_required) tweet, err := app.ensure_tweet(tweet_id, is_scrape_required, is_conversation_required)
if errors.Is(err, ErrNotFound) { var toasts []Toast
app.error_404(w) if err != nil {
return app.ErrorLog.Print(fmt.Errorf("TweetDetail (%d): %w", tweet_id, err))
if errors.Is(err, ErrNotFound) {
// Can't find the tweet; abort
app.toast(w, r, Toast{Title: "Not found", Message: "Tweet not found in database", Type: "error"})
return
} else if errors.Is(err, scraper.ErrSessionInvalidated) {
toasts = append(toasts, Toast{
Title: "Session invalidated",
Message: "Your session has been invalidated by Twitter. You'll have to log in again.",
Type: "error",
})
// TODO: delete the invalidated session
} else if errors.Is(err, scraper.ErrRateLimited) {
toasts = append(toasts, Toast{
Title: "Rate limited",
Message: "While scraping, a rate-limit was hit. Results may be incomplete.",
Type: "warning",
})
} else {
panic(err) // Let the 500 handler deal with it
}
} }
req_with_tweet := r.WithContext(add_tweet_to_context(r.Context(), tweet)) req_with_tweet := r.WithContext(add_tweet_to_context(r.Context(), tweet))
if len(parts) > 2 && parts[2] == "like" { if len(parts) > 2 && parts[2] == "like" {
@ -137,7 +161,7 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
app.buffered_render_page( app.buffered_render_page(
w, w,
"tpl/tweet_detail.tpl", "tpl/tweet_detail.tpl",
PageGlobalData{TweetTrove: twt_detail.TweetTrove, FocusedTweetID: data.MainTweetID}, PageGlobalData{TweetTrove: twt_detail.TweetTrove, FocusedTweetID: data.MainTweetID, Toasts: toasts},
data, data,
) )
} }

View File

@ -25,6 +25,7 @@ type PageGlobalData struct {
SearchText string SearchText string
FocusedTweetID scraper.TweetID FocusedTweetID scraper.TweetID
Notifications Notifications
Toasts []Toast
} }
func (d PageGlobalData) Tweet(id scraper.TweetID) scraper.Tweet { func (d PageGlobalData) Tweet(id scraper.TweetID) scraper.Tweet {

View File

@ -38,7 +38,7 @@ func (app *Application) error_500(w http.ResponseWriter, r *http.Request, err er
} }
func (app *Application) toast(w http.ResponseWriter, r *http.Request, t Toast) { func (app *Application) toast(w http.ResponseWriter, r *http.Request, t Toast) {
// Reset the HTMX response to return an error toast and put it in the // Reset the HTMX response to return an error toast and append it to the Toasts container
w.Header().Set("HX-Reswap", "beforeend") w.Header().Set("HX-Reswap", "beforeend")
w.Header().Set("HX-Retarget", "#toasts") w.Header().Set("HX-Retarget", "#toasts")
w.Header().Set("HX-Push-Url", "false") w.Header().Set("HX-Push-Url", "false")
@ -46,6 +46,12 @@ func (app *Application) toast(w http.ResponseWriter, r *http.Request, t Toast) {
app.buffered_render_htmx(w, "toast", PageGlobalData{}, t) app.buffered_render_htmx(w, "toast", PageGlobalData{}, t)
} }
// `Type` can be:
// - "success" (default)
// - "warning"
// - "error"
//
// If "AutoCloseDelay" is not 0, the toast will auto-disappear after that many milliseconds.
type Toast struct { type Toast struct {
Title string Title string
Message string Message string

View File

@ -50,6 +50,9 @@
</div> </div>
</dialog> </dialog>
<div class="toasts" id="toasts"> <div class="toasts" id="toasts">
{{range (global_data).Toasts}}
{{template "toast" .}}
{{end}}
</div> </div>
</body> </body>
</html> </html>

View File

@ -5,6 +5,9 @@
hx-on::load="setTimeout(() => this.remove(), {{.AutoCloseDelay}} + 2000); setTimeout(() => this.classList.add('disappearing'), {{.AutoCloseDelay}})" hx-on::load="setTimeout(() => this.remove(), {{.AutoCloseDelay}} + 2000); setTimeout(() => this.classList.add('disappearing'), {{.AutoCloseDelay}})"
{{end}} {{end}}
> >
{{if .Title}}
<h2 class="toast__title">{{.Title}}</h2>
{{end}}
<span class="toast__message">{{.Message}}</span> <span class="toast__message">{{.Message}}</span>
{{if not .AutoCloseDelay}} {{if not .AutoCloseDelay}}
<button class="suicide" onclick="this.parentElement.remove()">X</button> <button class="suicide" onclick="this.parentElement.remove()">X</button>