Add "with replies", "without replies", "media" and "likes" tab to User Feed

- Also add UI option to scrape a user's Likes from their user profile
This commit is contained in:
Alessio 2023-10-13 17:21:56 -03:00
parent d1edcbf363
commit 72df452401
5 changed files with 144 additions and 8 deletions

View File

@ -12,6 +12,7 @@ import (
type UserProfileData struct {
persistence.Feed
scraper.UserID
FeedType string
}
func (t UserProfileData) Tweet(id scraper.TweetID) scraper.Tweet {
@ -41,22 +42,42 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
return
}
if len(parts) == 2 && parts[1] == "scrape" {
if len(parts) > 1 && parts[len(parts)-1] == "scrape" {
if app.IsScrapingDisabled {
http.Error(w, "Scraping is disabled (are you logged in?)", 401)
return
}
// Run scraper
trove, err := scraper.GetUserFeedGraphqlFor(user.ID, 50) // TODO: parameterizable
if err != nil {
app.ErrorLog.Print(err)
// TOOD: show error in UI
if len(parts) == 2 { // Already checked the last part is "scrape"
// Run scraper
trove, err := scraper.GetUserFeedGraphqlFor(user.ID, 50) // TODO: parameterizable
if err != nil {
app.ErrorLog.Print(err)
// TOOD: show error in UI
}
app.Profile.SaveTweetTrove(trove)
} else if len(parts) == 3 && parts[1] == "likes" {
trove, err := scraper.GetUserLikes(user.ID, 50) // TODO: parameterizable
if err != nil {
app.ErrorLog.Print(err)
// TOOD: show error in UI
}
app.Profile.SaveTweetTrove(trove)
}
app.Profile.SaveTweetTrove(trove)
}
c := persistence.NewUserFeedCursor(user.Handle)
var c persistence.Cursor
if len(parts) > 1 && parts[1] == "likes" {
c = persistence.NewUserFeedLikesCursor(user.Handle)
} else {
c = persistence.NewUserFeedCursor(user.Handle)
}
if len(parts) > 1 && parts[1] == "without_replies" {
c.FilterReplies = persistence.EXCLUDE
}
if len(parts) > 1 && parts[1] == "media" {
c.FilterMedia = persistence.REQUIRE
}
err = parse_cursor_value(&c, r)
if err != nil {
app.error_400_with_message(w, "invalid cursor (must be a number)")
@ -74,6 +95,11 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
feed.Users[user.ID] = user
data := UserProfileData{Feed: feed, UserID: user.ID}
if len(parts) == 2 {
data.FeedType = parts[1]
} else {
data.FeedType = ""
}
if r.Header.Get("HX-Request") == "true" && c.CursorPosition == persistence.CURSOR_MIDDLE {
// It's a Show More request

View File

@ -133,6 +133,54 @@ func TestUserFeedWithCursorBadNumber(t *testing.T) {
require.Equal(resp.StatusCode, 400)
}
func TestUserFeedTweetsOnlyTab(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/Peter_Nimitz/without_replies", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
tweets := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweets, 2)
}
func TestUserFeedMediaTab(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/Cernovich/media", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
tweets := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweets, 1)
}
func TestUserFeedLikesTab(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/MysteryGrove/likes", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
tweets := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweets, 5)
// Double check pagination works properly
resp = do_request(httptest.NewRequest("GET", "/MysteryGrove/likes?cursor=5", nil))
require.Equal(resp.StatusCode, 200)
root, err = html.Parse(resp.Body)
require.NoError(err)
tweets = cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweets, 4)
}
// Timeline page
// -------------

View File

@ -221,6 +221,21 @@ h3 {
.user-feed-header .profile-image {
width: 8em;
}
.user-feed-header a.user-feed-tab {
flex-grow: 1;
text-align: center;
font-size: 1.1em;
font-weight: bold;
color: var(--color-twitter-text-gray);
padding: 0.8em;
}
.user-feed-header a.user-feed-tab.active-tab {
color: var(--color-twitter-blue);
border-bottom: 0.2em solid var(--color-twitter-blue);
}
.user-feed-header a.user-feed-tab:hover {
color: var(--color-twitter-blue);
}
.row {
display: flex;

View File

@ -59,10 +59,31 @@
<span>Re-fetch user feed</span>
</li>
</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 class="row user-feed-tabs-container">
<a class="user-feed-tab unstyled-link {{if (eq .FeedType "")}}active-tab{{end}}" href="/{{$user.Handle}}">
<span class="user-feed-tab-inner">Tweets and replies</span>
</a>
<a class="user-feed-tab unstyled-link {{if (eq .FeedType "without_replies")}}active-tab{{end}}" href="/{{$user.Handle}}/without_replies">
<span class="user-feed-tab-inner">Tweets</span>
</a>
<a class="user-feed-tab unstyled-link {{if (eq .FeedType "media")}}active-tab{{end}}" href="/{{$user.Handle}}/media">
<span class="user-feed-tab-inner">Media</span>
</a>
<a class="user-feed-tab unstyled-link {{if (eq .FeedType "likes")}}active-tab{{end}}" href="/{{$user.Handle}}/likes">
<span class="user-feed-tab-inner">Likes</span>
</a>
</div>
</div>
<div class="timeline user-feed-timeline">

View File

@ -180,6 +180,23 @@ func NewUserFeedCursor(h scraper.UserHandle) Cursor {
}
}
// Generate a cursor appropriate for a user's Media tab
func NewUserFeedMediaCursor(h scraper.UserHandle) Cursor {
return Cursor{
Keywords: []string{},
ToUserHandles: []scraper.UserHandle{},
SinceTimestamp: scraper.TimestampFromUnix(0),
UntilTimestamp: scraper.TimestampFromUnix(0),
CursorPosition: CURSOR_START,
CursorValue: 0,
SortOrder: SORT_ORDER_NEWEST,
PageSize: 50,
ByUserHandle: h,
FilterMedia: REQUIRE,
}
}
// Generate a cursor for a User's Likes
func NewUserFeedLikesCursor(h scraper.UserHandle) Cursor {
return Cursor{
@ -263,6 +280,7 @@ func (c *Cursor) apply_token(token string) error {
c.ToUserHandles = append(c.ToUserHandles, scraper.UserHandle(parts[1]))
case "retweeted_by":
c.RetweetedByUserHandle = scraper.UserHandle(parts[1])
c.FilterRetweets = NONE // Clear the "exclude retweets" filter set by default in NewCursor
case "liked_by":
c.LikedByUserHandle = scraper.UserHandle(parts[1])
case "since":
@ -283,6 +301,10 @@ func (c *Cursor) apply_token(token string) error {
c.FilterPolls = REQUIRE
case "spaces":
c.FilterSpaces = REQUIRE
case "replies":
c.FilterReplies = REQUIRE
case "retweets":
c.FilterRetweets = REQUIRE
}
case "-filter":
switch parts[1] {
@ -298,6 +320,10 @@ func (c *Cursor) apply_token(token string) error {
c.FilterPolls = EXCLUDE
case "spaces":
c.FilterSpaces = EXCLUDE
case "replies":
c.FilterReplies = EXCLUDE
case "retweets":
c.FilterRetweets = EXCLUDE
}
}