Change "scrape" from a URL param to a query param
- create generic "refresh" button for User Feed that refreshes whichever tab you're on - refactor TweetDetail view into multiple pieces for easier maintenance - on "Already liked this tweet" error, save the Like instead of discarding it
This commit is contained in:
parent
fa9c913b53
commit
694a8e0bc5
@ -11,6 +11,8 @@ import (
|
|||||||
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
type TweetDetailData struct {
|
type TweetDetailData struct {
|
||||||
persistence.TweetDetailView
|
persistence.TweetDetailView
|
||||||
MainTweetID scraper.TweetID
|
MainTweetID scraper.TweetID
|
||||||
@ -37,6 +39,77 @@ func (t TweetDetailData) FocusedTweetID() scraper.TweetID {
|
|||||||
return t.MainTweetID
|
return t.MainTweetID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *Application) ensure_tweet(id scraper.TweetID, is_forced bool, is_conversation_required bool) (scraper.Tweet, error) {
|
||||||
|
is_available := false
|
||||||
|
is_needing_scrape := is_forced
|
||||||
|
|
||||||
|
// Check if tweet is already in DB
|
||||||
|
tweet, err := app.Profile.GetTweetById(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, persistence.ErrNotInDB) {
|
||||||
|
is_needing_scrape = true
|
||||||
|
is_available = false
|
||||||
|
} else {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
is_available = true
|
||||||
|
if !tweet.IsConversationScraped {
|
||||||
|
is_needing_scrape = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_available && !is_conversation_required { // TODO: get rid of this, just force the fetch in subsequent handlers if needed
|
||||||
|
is_needing_scrape = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_needing_scrape && !app.IsScrapingDisabled {
|
||||||
|
trove, err := scraper.GetTweetFullAPIV2(id, 50) // TODO: parameterizable
|
||||||
|
if err == nil {
|
||||||
|
app.Profile.SaveTweetTrove(trove)
|
||||||
|
is_available = true
|
||||||
|
} else {
|
||||||
|
app.ErrorLog.Print(err)
|
||||||
|
// TODO: show error in UI
|
||||||
|
}
|
||||||
|
} else if is_needing_scrape {
|
||||||
|
app.InfoLog.Printf("Would have scraped Tweet: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_available {
|
||||||
|
return scraper.Tweet{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return tweet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) LikeTweet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tweet := get_tweet_from_context(r.Context())
|
||||||
|
like, err := scraper.LikeTweet(tweet.ID)
|
||||||
|
// "Already Liked This Tweet" is no big deal-- we can just update the UI as if it succeeded
|
||||||
|
if err != nil && !errors.Is(err, scraper.AlreadyLikedThisTweet) {
|
||||||
|
// It's a different error
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = app.Profile.SaveLike(like)
|
||||||
|
panic_if(err)
|
||||||
|
tweet.IsLikedByCurrentUser = true
|
||||||
|
|
||||||
|
app.buffered_render_basic_htmx(w, "likes-count", tweet)
|
||||||
|
}
|
||||||
|
func (app *Application) UnlikeTweet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tweet := get_tweet_from_context(r.Context())
|
||||||
|
err := scraper.UnlikeTweet(tweet.ID)
|
||||||
|
// As above, "Haven't Liked This Tweet" is no big deal-- we can just update the UI as if the request succeeded
|
||||||
|
if err != nil && !errors.Is(err, scraper.HaventLikedThisTweet) {
|
||||||
|
// It's a different error
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = app.Profile.DeleteLike(scraper.Like{UserID: app.ActiveUser.ID, TweetID: tweet.ID})
|
||||||
|
panic_if(err)
|
||||||
|
tweet.IsLikedByCurrentUser = false
|
||||||
|
|
||||||
|
app.buffered_render_basic_htmx(w, "likes-count", tweet)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
|
func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
app.traceLog.Printf("'TweetDetail' handler (path: %q)", r.URL.Path)
|
app.traceLog.Printf("'TweetDetail' handler (path: %q)", r.URL.Path)
|
||||||
|
|
||||||
@ -51,63 +124,21 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := NewTweetDetailData()
|
data := NewTweetDetailData()
|
||||||
data.MainTweetID = tweet_id
|
data.MainTweetID = tweet_id
|
||||||
|
|
||||||
is_needing_scrape := (len(parts) > 2 && parts[2] == "scrape")
|
is_scrape_required := r.URL.Query().Has("scrape")
|
||||||
is_available := false
|
is_conversation_required := len(parts) <= 2 || (parts[2] != "like" && parts[2] != "unlike")
|
||||||
|
|
||||||
// Check if tweet is already in DB
|
tweet, err := app.ensure_tweet(tweet_id, is_scrape_required, is_conversation_required)
|
||||||
tweet, err := app.Profile.GetTweetById(tweet_id)
|
if errors.Is(err, ErrNotFound) {
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, persistence.ErrNotInDB) {
|
|
||||||
is_needing_scrape = true
|
|
||||||
is_available = false
|
|
||||||
} else {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
is_available = true
|
|
||||||
if !tweet.IsConversationScraped {
|
|
||||||
is_needing_scrape = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if is_available && len(parts) > 2 && (parts[2] == "like" || parts[2] == "unlike") {
|
|
||||||
is_needing_scrape = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_needing_scrape && !app.IsScrapingDisabled {
|
|
||||||
trove, err := scraper.GetTweetFullAPIV2(tweet_id, 50) // TODO: parameterizable
|
|
||||||
if err == nil {
|
|
||||||
app.Profile.SaveTweetTrove(trove)
|
|
||||||
is_available = true
|
|
||||||
} else {
|
|
||||||
app.ErrorLog.Print(err)
|
|
||||||
// TODO: show error in UI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_available {
|
|
||||||
app.error_404(w)
|
app.error_404(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
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" {
|
||||||
like, err := scraper.LikeTweet(tweet.ID)
|
app.LikeTweet(w, req_with_tweet)
|
||||||
// if err != nil && !errors.Is(err, scraper.AlreadyLikedThisTweet) {}
|
|
||||||
panic_if(err)
|
|
||||||
fmt.Printf("Like: %#v\n", like)
|
|
||||||
err = app.Profile.SaveLike(like)
|
|
||||||
panic_if(err)
|
|
||||||
tweet.IsLikedByCurrentUser = true
|
|
||||||
|
|
||||||
app.buffered_render_basic_htmx(w, "likes-count", tweet)
|
|
||||||
return
|
return
|
||||||
} else if len(parts) > 2 && parts[2] == "unlike" {
|
} else if len(parts) > 2 && parts[2] == "unlike" {
|
||||||
err = scraper.UnlikeTweet(tweet_id)
|
app.UnlikeTweet(w, req_with_tweet)
|
||||||
panic_if(err)
|
|
||||||
err = app.Profile.DeleteLike(scraper.Like{UserID: app.ActiveUser.ID, TweetID: tweet.ID})
|
|
||||||
panic_if(err)
|
|
||||||
tweet.IsLikedByCurrentUser = false
|
|
||||||
|
|
||||||
app.buffered_render_basic_htmx(w, "likes-count", tweet)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,13 +42,14 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) > 1 && parts[len(parts)-1] == "scrape" {
|
if r.URL.Query().Has("scrape") {
|
||||||
if app.IsScrapingDisabled {
|
if app.IsScrapingDisabled {
|
||||||
|
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
|
||||||
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
|
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 2 { // Already checked the last part is "scrape"
|
if len(parts) == 1 { // The URL is just the user handle
|
||||||
// Run scraper
|
// Run scraper
|
||||||
trove, err := scraper.GetUserFeedGraphqlFor(user.ID, 50) // TODO: parameterizable
|
trove, err := scraper.GetUserFeedGraphqlFor(user.ID, 50) // TODO: parameterizable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -56,7 +57,7 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
// TOOD: show error in UI
|
// TOOD: show error in UI
|
||||||
}
|
}
|
||||||
app.Profile.SaveTweetTrove(trove)
|
app.Profile.SaveTweetTrove(trove)
|
||||||
} else if len(parts) == 3 && parts[1] == "likes" {
|
} else if len(parts) == 2 && parts[1] == "likes" {
|
||||||
trove, err := scraper.GetUserLikes(user.ID, 50) // TODO: parameterizable
|
trove, err := scraper.GetUserLikes(user.ID, 50) // TODO: parameterizable
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.ErrorLog.Print(err)
|
app.ErrorLog.Print(err)
|
||||||
|
@ -2,6 +2,7 @@ package webserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
@ -244,3 +245,15 @@ func cursor_to_query_params(c persistence.Cursor) string {
|
|||||||
result.Set("sort-order", c.SortOrder.String())
|
result.Set("sort-order", c.SortOrder.String())
|
||||||
return result.Encode()
|
return result.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func add_tweet_to_context(ctx context.Context, tweet scraper.Tweet) context.Context {
|
||||||
|
return context.WithValue(ctx, "tweet", tweet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func get_tweet_from_context(ctx context.Context) scraper.Tweet {
|
||||||
|
tweet, is_ok := ctx.Value("tweet").(scraper.Tweet)
|
||||||
|
if !is_ok {
|
||||||
|
panic("Tweet not found in context")
|
||||||
|
}
|
||||||
|
return tweet
|
||||||
|
}
|
||||||
|
1
internal/webserver/static/icons/refresh.svg
Normal file
1
internal/webserver/static/icons/refresh.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-refresh" viewBox="0 0 24 24"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" /></svg>
|
After Width: | Height: | Size: 306 B |
@ -3,7 +3,7 @@
|
|||||||
{{$author := (user $main_tweet.UserID)}}
|
{{$author := (user $main_tweet.UserID)}}
|
||||||
<div class="tweet"
|
<div class="tweet"
|
||||||
{{if (not (eq $main_tweet.ID (focused_tweet_id)))}}
|
{{if (not (eq $main_tweet.ID (focused_tweet_id)))}}
|
||||||
hx-post="/tweet/{{$main_tweet.ID}}"
|
hx-get="/tweet/{{$main_tweet.ID}}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="outerHTML show:#focused-tweet:top"
|
hx-swap="outerHTML show:#focused-tweet:top"
|
||||||
@ -130,7 +130,7 @@
|
|||||||
<span>Open on twitter.com</span>
|
<span>Open on twitter.com</span>
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
<a class="unstyled-link" target="_blank" hx-post="/tweet/{{$main_tweet.ID}}/scrape" hx-target="body">
|
<a class="unstyled-link" target="_blank" hx-get="/tweet/{{$main_tweet.ID}}?scrape" hx-target="body">
|
||||||
<li class="quick-link">
|
<li class="quick-link">
|
||||||
<img class="svg-icon" src="/static/icons/download.svg" />
|
<img class="svg-icon" src="/static/icons/download.svg" />
|
||||||
<span>Re-fetch tweet</span>
|
<span>Re-fetch tweet</span>
|
||||||
|
@ -42,30 +42,18 @@
|
|||||||
<span class="following-count">{{$user.FollowingCount}}</span>
|
<span class="following-count">{{$user.FollowingCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown" hx-trigger="click consume">
|
|
||||||
<button class="dropdown-button" title="Options">
|
|
||||||
<img class="svg-icon" src="/static/icons/more.svg" />
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-items">
|
|
||||||
<a class="unstyled-link" target="_blank" href="https://twitter.com/{{$user.Handle}}">
|
<a class="unstyled-link" target="_blank" href="https://twitter.com/{{$user.Handle}}">
|
||||||
<li class="quick-link">
|
<li class="quick-link">
|
||||||
<img class="svg-icon" src="/static/icons/external-link.svg" />
|
<img class="svg-icon" src="/static/icons/external-link.svg" />
|
||||||
<span>Open on twitter.com</span>
|
<span>Open on twitter.com</span>
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
<a class="unstyled-link" target="_blank" hx-post="/{{$user.Handle}}/scrape" hx-target="body">
|
<div class="XXX">
|
||||||
|
<a class="unstyled-link" title="Refresh" hx-get="?scrape" hx-target="body">
|
||||||
<li class="quick-link">
|
<li class="quick-link">
|
||||||
<img class="svg-icon" src="/static/icons/download.svg" />
|
<img class="svg-icon" src="/static/icons/refresh.svg" />
|
||||||
<span>Re-fetch user feed</span>
|
|
||||||
</li>
|
</li>
|
||||||
</a>
|
</a>
|
||||||
<a class="unstyled-link" target="_blank" hx-post="/{{$user.Handle}}/likes/scrape" hx-target="body">
|
|
||||||
<li class="quick-link">
|
|
||||||
<img class="svg-icon" src="/static/icons/download.svg" />
|
|
||||||
<span>Re-fetch user likes</span>
|
|
||||||
</li>
|
|
||||||
</a>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,7 +34,11 @@ func (api API) LikeTweet(id TweetID) (Like, error) {
|
|||||||
}
|
}
|
||||||
if len(result.Errors) > 0 {
|
if len(result.Errors) > 0 {
|
||||||
if strings.Contains(result.Errors[0].Message, "has already favorited tweet") {
|
if strings.Contains(result.Errors[0].Message, "has already favorited tweet") {
|
||||||
return Like{}, AlreadyLikedThisTweet
|
return Like{
|
||||||
|
UserID: api.UserID,
|
||||||
|
TweetID: id,
|
||||||
|
SortID: -1,
|
||||||
|
}, AlreadyLikedThisTweet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.Data.FavoriteTweet != "Done" {
|
if result.Data.FavoriteTweet != "Done" {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user