Add /user/followers and /user/followees pages

This commit is contained in:
Alessio 2023-12-26 23:16:51 -06:00
parent dd68ee1fce
commit 3117a1364c
11 changed files with 106 additions and 41 deletions

View File

@ -18,5 +18,5 @@ func (app *Application) Lists(w http.ResponseWriter, r *http.Request) {
where is_followed = 1`) where is_followed = 1`)
panic_if(err) panic_if(err)
app.buffered_render_basic_page(w, "tpl/list.tpl", users) app.buffered_render_basic_page(w, "tpl/list.tpl", ListData{Title: "Offline Follows", Users: users})
} }

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"context"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence" "gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper" "gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
@ -151,3 +152,19 @@ func (app *Application) TweetDetail(w http.ResponseWriter, r *http.Request) {
app.buffered_render_tweet_page(w, "tpl/tweet_detail.tpl", data) app.buffered_render_tweet_page(w, "tpl/tweet_detail.tpl", data)
} }
type key string
const TWEET_KEY = key("tweet")
func add_tweet_to_context(ctx context.Context, tweet scraper.Tweet) context.Context {
return context.WithValue(ctx, TWEET_KEY, tweet)
}
func get_tweet_from_context(ctx context.Context) scraper.Tweet {
tweet, is_ok := ctx.Value(TWEET_KEY).(scraper.Tweet)
if !is_ok {
panic("Tweet not found in context")
}
return tweet
}

View File

@ -1,6 +1,7 @@
package webserver package webserver
import ( import (
"fmt"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
@ -49,6 +50,15 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
panic_if(app.Profile.DownloadUserContentFor(&user)) panic_if(app.Profile.DownloadUserContentFor(&user))
} }
if len(parts) > 1 && parts[1] == "followers" {
app.UserFollowers(w, r, user)
return
}
if len(parts) > 1 && parts[1] == "followees" {
app.UserFollowees(w, r, user)
return
}
if r.URL.Query().Has("scrape") { if r.URL.Query().Has("scrape") {
if app.IsScrapingDisabled { if app.IsScrapingDisabled {
app.InfoLog.Printf("Would have scraped: %s", r.URL.Path) app.InfoLog.Printf("Would have scraped: %s", r.URL.Path)
@ -118,3 +128,21 @@ func (app *Application) UserFeed(w http.ResponseWriter, r *http.Request) {
app.buffered_render_tweet_page(w, "tpl/user_feed.tpl", data) app.buffered_render_tweet_page(w, "tpl/user_feed.tpl", data)
} }
} }
type ListData struct {
Title string
Users []scraper.User
}
func (app *Application) UserFollowees(w http.ResponseWriter, r *http.Request, user scraper.User) {
app.buffered_render_basic_page(w, "tpl/list.tpl", ListData{
Title: fmt.Sprintf("Followed by @%s", user.Handle),
Users: app.Profile.GetFollowees(user.ID),
})
}
func (app *Application) UserFollowers(w http.ResponseWriter, r *http.Request, user scraper.User) {
app.buffered_render_basic_page(w, "tpl/list.tpl", ListData{
Title: fmt.Sprintf("Followers of @%s", user.Handle),
Users: app.Profile.GetFollowers(user.ID),
})
}

View File

@ -2,7 +2,6 @@ package webserver
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
@ -249,19 +248,3 @@ 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()
} }
type key string
const TWEET_KEY = key("tweet")
func add_tweet_to_context(ctx context.Context, tweet scraper.Tweet) context.Context {
return context.WithValue(ctx, TWEET_KEY, tweet)
}
func get_tweet_from_context(ctx context.Context) scraper.Tweet {
tweet, is_ok := ctx.Value(TWEET_KEY).(scraper.Tweet)
if !is_ok {
panic("Tweet not found in context")
}
return tweet
}

View File

@ -79,7 +79,7 @@ func TestUserFeed(t *testing.T) {
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
require.NoError(err) require.NoError(err)
title_node := cascadia.Query(root, selector("title")) title_node := cascadia.Query(root, selector("title"))
assert.Equal(title_node.FirstChild.Data, "Offline Twitter | @Cernovich") assert.Equal(title_node.FirstChild.Data, "@Cernovich | Offline Twitter")
tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet")) tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweet_nodes, 7) assert.Len(tweet_nodes, 7)
@ -119,7 +119,7 @@ func TestUserFeedWithCursor(t *testing.T) {
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
require.NoError(err) require.NoError(err)
title_node := cascadia.Query(root, selector("title")) title_node := cascadia.Query(root, selector("title"))
assert.Equal(title_node.FirstChild.Data, "Offline Twitter | @Cernovich") assert.Equal(title_node.FirstChild.Data, "@Cernovich | Offline Twitter")
tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet")) tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweet_nodes, 2) assert.Len(tweet_nodes, 2)
@ -181,6 +181,34 @@ func TestUserFeedLikesTab(t *testing.T) {
assert.Len(tweets, 4) assert.Len(tweets, 4)
} }
// Followers and followees
// -----------------------
func TestUserFollowers(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/Offline_Twatter/followers", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".users-list-container > .user")), 2)
}
func TestUserFollowees(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/Offline_Twatter/followees", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".users-list-container > .user")), 1)
}
// Timeline page // Timeline page
// ------------- // -------------
@ -194,7 +222,7 @@ func TestTimeline(t *testing.T) {
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
require.NoError(err) require.NoError(err)
title_node := cascadia.Query(root, selector("title")) title_node := cascadia.Query(root, selector("title"))
assert.Equal(title_node.FirstChild.Data, "Offline Twitter | Timeline") assert.Equal(title_node.FirstChild.Data, "Timeline | Offline Twitter")
tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet")) tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweet_nodes, 18) assert.Len(tweet_nodes, 18)
@ -210,7 +238,7 @@ func TestTimelineWithCursor(t *testing.T) {
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
require.NoError(err) require.NoError(err)
title_node := cascadia.Query(root, selector("title")) title_node := cascadia.Query(root, selector("title"))
assert.Equal(title_node.FirstChild.Data, "Offline Twitter | Timeline") assert.Equal(title_node.FirstChild.Data, "Timeline | Offline Twitter")
tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet")) tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweet_nodes, 10) assert.Len(tweet_nodes, 10)
@ -245,7 +273,7 @@ func TestSearch(t *testing.T) {
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
require.NoError(err) require.NoError(err)
title_node := cascadia.Query(root, selector("title")) title_node := cascadia.Query(root, selector("title"))
assert.Equal(title_node.FirstChild.Data, "Offline Twitter | Search") assert.Equal(title_node.FirstChild.Data, "Search | Offline Twitter")
tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet")) tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
assert.Len(tweet_nodes, 1) assert.Len(tweet_nodes, 1)

View File

@ -3,7 +3,7 @@
<html lang='en'> <html lang='en'>
<head> <head>
<meta charset='utf-8'> <meta charset='utf-8'>
<title>Offline Twitter | {{template "title" .}}</title> <title>{{template "title" .}} | Offline Twitter</title>
<link rel='stylesheet' href='/static/styles.css'> <link rel='stylesheet' href='/static/styles.css'>
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'> <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
<link rel='stylesheet' href='/static/vendor/fonts.css'> <link rel='stylesheet' href='/static/vendor/fonts.css'>

View File

@ -1,5 +1,5 @@
{{define "title"}}Followed Users{{end}} {{define "title"}}{{.Title}}{{end}}
{{define "main"}} {{define "main"}}
{{template "list" .}} {{template "list" .Users}}
{{end}} {{end}}

View File

@ -37,14 +37,14 @@
</div> </div>
<div class="followers-followees-container row"> <div class="followers-followees-container row">
<div class="followers-container"> <a href="/{{$user.Handle}}/followers" class="followers-container unstyled-link">
<span class="followers-count">{{$user.FollowersCount}}</span> <span class="followers-count">{{$user.FollowersCount}}</span>
<span class="followers-label">followers</span> <span class="followers-label">followers</span>
</div> </a>
<div class="followees-container"> <a href="/{{$user.Handle}}/followees" class="followers-container unstyled-link">
<span class="following-label">is following</span> <span class="following-label">is following</span>
<span class="following-count">{{$user.FollowingCount}}</span> <span class="following-count">{{$user.FollowingCount}}</span>
</div> </a>
<div class="spacer"></div> <div class="spacer"></div>

View File

@ -39,18 +39,26 @@ func (p Profile) IsXFollowingY(follower_id UserID, followee_id UserID) bool {
return rows.Next() // true if there is a row, false otherwise return rows.Next() // true if there is a row, false otherwise
} }
func (p Profile) GetFollowers(followee_id UserID) []UserID { func (p Profile) GetFollowers(followee_id UserID) []User {
var ret []UserID var ret []User
err := p.DB.Select(&ret, `select follower_id from follows where followee_id = ?`, followee_id) err := p.DB.Select(&ret, `
select `+USERS_ALL_SQL_FIELDS+`
from users
where id in (select follower_id from follows where followee_id = ?)
`, followee_id)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return ret return ret
} }
func (p Profile) GetFollowees(follower_id UserID) []UserID { func (p Profile) GetFollowees(follower_id UserID) []User {
var ret []UserID var ret []User
err := p.DB.Select(&ret, `select followee_id from follows where follower_id = ?`, follower_id) err := p.DB.Select(&ret, `
select `+USERS_ALL_SQL_FIELDS+`
from users
where id in (select followee_id from follows where follower_id = ?)
`, follower_id)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -34,11 +34,11 @@ func TestSaveAndLoadFollows(t *testing.T) {
// Save and reload it // Save and reload it
profile.SaveAsFolloweesList(follower.ID, trove) profile.SaveAsFolloweesList(follower.ID, trove)
new_followee_ids := profile.GetFollowees(follower.ID) new_followees := profile.GetFollowees(follower.ID)
assert.Len(new_followee_ids, len(followee_ids)) assert.Len(new_followees, len(followee_ids))
for _, id := range new_followee_ids { for _, followee := range new_followees {
_, is_ok := trove.Users[id] _, is_ok := trove.Users[followee.ID]
assert.True(is_ok) assert.True(is_ok)
} }
} }

View File

@ -421,7 +421,8 @@ create index if not exists index_follows_followee_id on follows (followee_id);
create index if not exists index_follows_follower_id on follows (follower_id); create index if not exists index_follows_follower_id on follows (follower_id);
insert into follows values insert into follows values
(1, 1178839081222115328, 1488963321701171204), (1, 1178839081222115328, 1488963321701171204),
(2, 1032468021485293568, 1488963321701171204); (2, 1032468021485293568, 1488963321701171204),
(3, 1488963321701171204, 1240784920831762433);
create table fake_user_sequence(latest_fake_id integer not null); create table fake_user_sequence(latest_fake_id integer not null);