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:
parent
d1edcbf363
commit
72df452401
@ -12,6 +12,7 @@ import (
|
|||||||
type UserProfileData struct {
|
type UserProfileData struct {
|
||||||
persistence.Feed
|
persistence.Feed
|
||||||
scraper.UserID
|
scraper.UserID
|
||||||
|
FeedType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t UserProfileData) Tweet(id scraper.TweetID) scraper.Tweet {
|
func (t UserProfileData) Tweet(id scraper.TweetID) scraper.Tweet {
|
||||||
@ -41,12 +42,13 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 2 && parts[1] == "scrape" {
|
if len(parts) > 1 && parts[len(parts)-1] == "scrape" {
|
||||||
if app.IsScrapingDisabled {
|
if app.IsScrapingDisabled {
|
||||||
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"
|
||||||
// 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 {
|
||||||
@ -54,9 +56,28 @@ 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" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
err = parse_cursor_value(&c, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.error_400_with_message(w, "invalid cursor (must be a number)")
|
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
|
feed.Users[user.ID] = user
|
||||||
|
|
||||||
data := UserProfileData{Feed: feed, UserID: user.ID}
|
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 {
|
if r.Header.Get("HX-Request") == "true" && c.CursorPosition == persistence.CURSOR_MIDDLE {
|
||||||
// It's a Show More request
|
// It's a Show More request
|
||||||
|
@ -133,6 +133,54 @@ func TestUserFeedWithCursorBadNumber(t *testing.T) {
|
|||||||
require.Equal(resp.StatusCode, 400)
|
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
|
// Timeline page
|
||||||
// -------------
|
// -------------
|
||||||
|
|
||||||
|
@ -221,6 +221,21 @@ h3 {
|
|||||||
.user-feed-header .profile-image {
|
.user-feed-header .profile-image {
|
||||||
width: 8em;
|
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 {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -59,10 +59,31 @@
|
|||||||
<span>Re-fetch user feed</span>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="timeline user-feed-timeline">
|
<div class="timeline user-feed-timeline">
|
||||||
|
@ -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
|
// Generate a cursor for a User's Likes
|
||||||
func NewUserFeedLikesCursor(h scraper.UserHandle) Cursor {
|
func NewUserFeedLikesCursor(h scraper.UserHandle) Cursor {
|
||||||
return Cursor{
|
return Cursor{
|
||||||
@ -263,6 +280,7 @@ func (c *Cursor) apply_token(token string) error {
|
|||||||
c.ToUserHandles = append(c.ToUserHandles, scraper.UserHandle(parts[1]))
|
c.ToUserHandles = append(c.ToUserHandles, scraper.UserHandle(parts[1]))
|
||||||
case "retweeted_by":
|
case "retweeted_by":
|
||||||
c.RetweetedByUserHandle = scraper.UserHandle(parts[1])
|
c.RetweetedByUserHandle = scraper.UserHandle(parts[1])
|
||||||
|
c.FilterRetweets = NONE // Clear the "exclude retweets" filter set by default in NewCursor
|
||||||
case "liked_by":
|
case "liked_by":
|
||||||
c.LikedByUserHandle = scraper.UserHandle(parts[1])
|
c.LikedByUserHandle = scraper.UserHandle(parts[1])
|
||||||
case "since":
|
case "since":
|
||||||
@ -283,6 +301,10 @@ func (c *Cursor) apply_token(token string) error {
|
|||||||
c.FilterPolls = REQUIRE
|
c.FilterPolls = REQUIRE
|
||||||
case "spaces":
|
case "spaces":
|
||||||
c.FilterSpaces = REQUIRE
|
c.FilterSpaces = REQUIRE
|
||||||
|
case "replies":
|
||||||
|
c.FilterReplies = REQUIRE
|
||||||
|
case "retweets":
|
||||||
|
c.FilterRetweets = REQUIRE
|
||||||
}
|
}
|
||||||
case "-filter":
|
case "-filter":
|
||||||
switch parts[1] {
|
switch parts[1] {
|
||||||
@ -298,6 +320,10 @@ func (c *Cursor) apply_token(token string) error {
|
|||||||
c.FilterPolls = EXCLUDE
|
c.FilterPolls = EXCLUDE
|
||||||
case "spaces":
|
case "spaces":
|
||||||
c.FilterSpaces = EXCLUDE
|
c.FilterSpaces = EXCLUDE
|
||||||
|
case "replies":
|
||||||
|
c.FilterReplies = EXCLUDE
|
||||||
|
case "retweets":
|
||||||
|
c.FilterRetweets = EXCLUDE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user