Add Lists pages

- List index page
- List detail pages: Feed and Users
- Improve styling for page headings (e.g., Search, Follows, list title, etc)
This commit is contained in:
Alessio 2024-02-25 17:14:09 -08:00
parent e0266667d6
commit 3f66daef68
8 changed files with 274 additions and 41 deletions

View File

@ -1,22 +1,28 @@
package webserver package webserver
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"strconv"
"strings"
"gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper" "gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
. "gitlab.com/offline-twitter/twitter_offline_engine/pkg/scraper"
) )
type ListData struct { type ListData struct {
Title string List
HeaderUserID scraper.UserID Feed persistence.Feed
HeaderTweetID scraper.TweetID UserIDs []UserID
UserIDs []scraper.UserID ActiveTab string
} }
func NewListData(users []scraper.User) (ListData, scraper.TweetTrove) { func NewListData(users []User) (ListData, TweetTrove) {
trove := scraper.NewTweetTrove() trove := NewTweetTrove()
data := ListData{ data := ListData{
UserIDs: []scraper.UserID{}, UserIDs: []UserID{},
} }
for _, u := range users { for _, u := range users {
trove.Users[u.ID] = u trove.Users[u.ID] = u
@ -25,19 +31,139 @@ func NewListData(users []scraper.User) (ListData, scraper.TweetTrove) {
return data, trove return data, trove
} }
func (app *Application) ListDetailFeed(w http.ResponseWriter, r *http.Request) {
list := get_list_from_context(r.Context())
c := persistence.NewListCursor(list.ID)
err := parse_cursor_value(&c, r)
if err != nil {
app.error_400_with_message(w, "invalid cursor (must be a number)")
return
}
feed, err := app.Profile.NextPage(c, app.ActiveUser.ID)
if err != nil && !errors.Is(err, persistence.ErrEndOfFeed) {
panic(err)
}
if r.Header.Get("HX-Request") == "true" && c.CursorPosition == persistence.CURSOR_MIDDLE {
// It's a Show More request
app.buffered_render_htmx(w, "timeline", PageGlobalData{TweetTrove: feed.TweetTrove}, feed)
} else {
app.buffered_render_page(
w,
"tpl/list.tpl",
PageGlobalData{TweetTrove: feed.TweetTrove},
ListData{Feed: feed, List: list, ActiveTab: "feed"},
)
}
}
func (app *Application) ListDetailUsers(w http.ResponseWriter, r *http.Request) {
list := get_list_from_context(r.Context())
users := app.Profile.GetListUsers(list.ID)
data, trove := NewListData(users)
data.List = list
data.ActiveTab = "users"
app.buffered_render_page(w, "tpl/list.tpl", PageGlobalData{TweetTrove: trove}, data)
}
func (app *Application) ListDetail(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'ListDetail' handler (path: %q)", r.URL.Path)
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) == 1 && parts[0] == "" {
// No further path; just show the feed
app.ListDetailFeed(w, r)
}
switch parts[0] {
case "users":
app.ListDetailUsers(w, r)
case "add_user":
app.ListAddUser(w, r)
case "remove_user":
app.ListRemoveUser(w, r)
default:
app.error_404(w)
}
}
func (app *Application) ListAddUser(w http.ResponseWriter, r *http.Request) {
handle := r.URL.Query().Get("user_handle")
if handle[0] == '@' {
handle = handle[1:]
}
user, err := app.Profile.GetUserByHandle(UserHandle(handle))
if err != nil {
app.error_400_with_message(w, "Fetch user: "+err.Error())
return
}
list := get_list_from_context(r.Context())
app.Profile.SaveListUser(list.ID, user.ID)
http.Redirect(w, r, fmt.Sprintf("/lists/%d", list.ID), 302)
}
func (app *Application) ListRemoveUser(w http.ResponseWriter, r *http.Request) {
handle := r.URL.Query().Get("user_handle")
if handle[0] == '@' {
handle = handle[1:]
}
user, err := app.Profile.GetUserByHandle(UserHandle(handle))
if err != nil {
app.error_400_with_message(w, "Fetch user: "+err.Error())
return
}
list := get_list_from_context(r.Context())
app.Profile.DeleteListUser(list.ID, user.ID)
http.Redirect(w, r, fmt.Sprintf("/lists/%d", list.ID), 302)
}
func (app *Application) Lists(w http.ResponseWriter, r *http.Request) { func (app *Application) Lists(w http.ResponseWriter, r *http.Request) {
app.traceLog.Printf("'Lists' handler (path: %q)", r.URL.Path) app.traceLog.Printf("'Lists' handler (path: %q)", r.URL.Path)
var users []scraper.User parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
err := app.Profile.DB.Select(&users, `
select id, display_name, handle, bio, following_count, followers_count, location, website, join_date, is_private, is_verified,
is_banned, is_deleted, profile_image_url, profile_image_local_path, banner_image_url, banner_image_local_path,
pinned_tweet_id, is_content_downloaded, is_followed
from users
where is_followed = 1`)
panic_if(err)
data, trove := NewListData(users) // List detail
data.Title = "Offline Follows" if parts[0] != "" { // If there's an ID param
app.buffered_render_page(w, "tpl/list.tpl", PageGlobalData{TweetTrove: trove}, data) _list_id, err := strconv.Atoi(parts[0])
if err != nil {
app.error_400_with_message(w, "List ID must be a number")
return
}
// XXX: Check that the list exists
// Need to modify signature to return an error, because it might be ErrNoRows
list := app.Profile.GetListById(ListID(_list_id))
req_with_ctx := r.WithContext(add_list_to_context(r.Context(), list))
http.StripPrefix(fmt.Sprintf("/%d", list.ID), http.HandlerFunc(app.ListDetail)).ServeHTTP(w, req_with_ctx)
return
}
// List index
lists := app.Profile.GetAllLists()
trove := NewTweetTrove()
for _, l := range lists {
for _, u := range l.Users {
trove.Users[u.ID] = u
}
}
app.buffered_render_page(
w,
"tpl/list_of_lists.tpl",
PageGlobalData{TweetTrove: trove},
lists,
)
}
const LIST_KEY = key("list") // type `key` is defined in "handler_tweet_detail"
func add_list_to_context(ctx context.Context, list List) context.Context {
return context.WithValue(ctx, LIST_KEY, list)
}
func get_list_from_context(ctx context.Context) List {
list, is_ok := ctx.Value(LIST_KEY).(List)
if !is_ok {
panic("List not found in context")
}
return list
} }

View File

@ -124,7 +124,7 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "search": case "search":
http.StripPrefix("/search", http.HandlerFunc(app.Search)).ServeHTTP(w, r) http.StripPrefix("/search", http.HandlerFunc(app.Search)).ServeHTTP(w, r)
case "lists": case "lists":
app.Lists(w, r) http.StripPrefix("/lists", http.HandlerFunc(app.Lists)).ServeHTTP(w, r)
case "messages": case "messages":
http.StripPrefix("/messages", http.HandlerFunc(app.Messages)).ServeHTTP(w, r) http.StripPrefix("/messages", http.HandlerFunc(app.Messages)).ServeHTTP(w, r)
default: default:

View File

@ -590,12 +590,39 @@ func TestStaticFileNonexistent(t *testing.T) {
// Lists // Lists
// ----- // -----
func TestLists(t *testing.T) { func TestListsIndex(t *testing.T) {
assert := assert.New(t) require := require.New(t)
resp := do_request(httptest.NewRequest("GET", "/lists", nil)) resp := do_request(httptest.NewRequest("GET", "/lists", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body) root, err := html.Parse(resp.Body)
assert.NoError(err) require.NoError(err)
// Check that there's 2 Lists
assert.Len(t, cascadia.QueryAll(root, selector(".users-list-preview")), 2)
}
func TestListDetail(t *testing.T) {
require := require.New(t)
assert := assert.New(t)
// Users
resp := do_request(httptest.NewRequest("GET", "/lists/1/users", nil))
require.Equal(resp.StatusCode, 200)
root, err := html.Parse(resp.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root, selector(".users-list-container .author-info")), 5) assert.Len(cascadia.QueryAll(root, selector(".users-list-container .author-info")), 5)
// Feed
resp1 := do_request(httptest.NewRequest("GET", "/lists/2", nil))
require.Equal(resp1.StatusCode, 200)
root1, err := html.Parse(resp1.Body)
require.NoError(err)
assert.Len(cascadia.QueryAll(root1, selector(".timeline > .tweet")), 3)
}
func TestListDetailInvalidId(t *testing.T) {
resp := do_request(httptest.NewRequest("GET", "/lists/asd", nil))
require.Equal(t, resp.StatusCode, 400)
} }
// Messages // Messages

View File

@ -51,6 +51,11 @@ input, select {
border-radius: 0.5em; border-radius: 0.5em;
} }
h1 {
margin: 0.5em 0;
text-align: center;
}
.server-error-msg { .server-error-msg {
position: fixed; position: fixed;
margin: 6em 0; margin: 6em 0;
@ -267,7 +272,7 @@ h3 {
width: 3em; width: 3em;
height: 3em; height: 3em;
display: inline; display: inline;
border: 1px solid var(--color-outline-gray); border: 0.1em solid var(--color-outline-gray);
} }
.user-header { .user-header {
@ -657,9 +662,46 @@ ul.space-participants-list li {
filter: invert(20%) sepia(97%) saturate(4383%) hue-rotate(321deg) brightness(101%) contrast(95%); filter: invert(20%) sepia(97%) saturate(4383%) hue-rotate(321deg) brightness(101%) contrast(95%);
} }
.users-list-previews {
border-color: var(--color-twitter-off-white-dark);
border-top-style: double;
border-width: 4px;
}
.users-list-preview {
cursor: pointer;
display: flex;
font-size: 1.5em;
border-color: var(--color-twitter-off-white-dark);
border-bottom-style: solid;
border-width: 1px;
padding: 0.5em 1em;
align-items: center;
}
.users-list-preview span.num-users {
margin-left: 1em;
color: var(--color-twitter-text-gray);
}
.users-list-preview .first-N-profile-images {
display: flex;
align-items: flex-end;
margin-left: 1.5em;
}
.users-list-preview .first-N-profile-images a {
margin-right: -1.2em;
line-height: 0; /* TODO: This is duplicated from `.author-info a` and possibly others */
}
.users-list-preview .first-N-profile-images .ellipsis {
margin-left: 1.5em;
}
.users-list-container { .users-list-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-color: var(--color-twitter-off-white-dark);
border-top-style: double;
border-width: 4px;
} }
.users-list-container .author-info .profile-image { .users-list-container .author-info .profile-image {
width: 4em; width: 4em;

View File

@ -1,15 +1,31 @@
{{define "title"}}{{.Title}}{{end}} {{define "title"}}{{.List.Name}}{{end}}
{{define "main"}} {{define "main"}}
{{if .HeaderUserID}} <div class="list-feed-header">
{{template "user-header" (user .HeaderUserID)}} <h1>{{.List.Name}}</h1>
{{else if .HeaderTweetID}}
{{template "tweet" (tweet .HeaderTweetID)}}
{{end}}
<h3> <div class="row tabs-container">
{{.Title}} <a class="tab unstyled-link {{if (eq .ActiveTab "feed")}}active-tab{{end}}" href="/lists/{{.List.ID}}">
</h3> <span class="tab-inner">Feed</span>
</a>
<a class="tab unstyled-link {{if (eq .ActiveTab "users")}}active-tab{{end}}" href="/lists/{{.List.ID}}/users">
<span class="tab-inner">Users</span>
</a>
</div>
</div>
{{if (eq .ActiveTab "feed")}}
<div class="timeline list-feed-timeline">
{{template "timeline" .Feed}}
</div>
{{else}}
<div class="add-users-container">
<form action="/lists/{{.List.ID}}/add_user">
<input type="text" name="user_handle" placeholder="@some_user_handle" />
<input type="submit" value="Add user" />
</form>
</div>
{{template "list" .UserIDs}} {{template "list" .UserIDs}}
{{end}}
{{end}} {{end}}

View File

@ -2,7 +2,7 @@
{{define "main"}} {{define "main"}}
<div class="search-header"> <div class="search-header">
<h2 style="text-align: center">Search results: {{.SearchText}}</h2> <h1>Search results: {{.SearchText}}</h1>
<div class="row tabs-container"> <div class="row tabs-container">
<a <a

View File

@ -129,11 +129,12 @@ type Cursor struct {
// Search params // Search params
Keywords []string Keywords []string
FromUserHandle scraper.UserHandle FromUserHandle scraper.UserHandle // Tweeted by this user
RetweetedByUserHandle scraper.UserHandle RetweetedByUserHandle scraper.UserHandle // Retweeted by this user
ByUserHandle scraper.UserHandle ByUserHandle scraper.UserHandle // Either tweeted or retweeted by this user
ToUserHandles []scraper.UserHandle ToUserHandles []scraper.UserHandle // In reply to these users
LikedByUserHandle scraper.UserHandle LikedByUserHandle scraper.UserHandle // Liked by this user
ListID scraper.ListID // Either tweeted or retweeted by users from this List
SinceTimestamp scraper.Timestamp SinceTimestamp scraper.Timestamp
UntilTimestamp scraper.Timestamp UntilTimestamp scraper.Timestamp
FilterLinks Filter FilterLinks Filter
@ -179,6 +180,21 @@ func NewTimelineCursor() Cursor {
} }
} }
// Generate a cursor appropriate for showing a List feed
func NewListCursor(list_id scraper.ListID) Cursor {
return Cursor{
Keywords: []string{},
ToUserHandles: []scraper.UserHandle{},
ListID: list_id,
SinceTimestamp: scraper.TimestampFromUnix(0),
UntilTimestamp: scraper.TimestampFromUnix(0),
CursorPosition: CURSOR_START,
CursorValue: 0,
SortOrder: SORT_ORDER_NEWEST,
PageSize: 50,
}
}
// Generate a cursor appropriate for fetching a User Feed // Generate a cursor appropriate for fetching a User Feed
func NewUserFeedCursor(h scraper.UserHandle) Cursor { func NewUserFeedCursor(h scraper.UserHandle) Cursor {
return Cursor{ return Cursor{
@ -375,6 +391,10 @@ func (p Profile) NextPage(c Cursor, current_user_id scraper.UserID) (Feed, error
where_clauses = append(where_clauses, "by_user_id = (select id from users where handle like ?)") where_clauses = append(where_clauses, "by_user_id = (select id from users where handle like ?)")
bind_values = append(bind_values, c.ByUserHandle) bind_values = append(bind_values, c.ByUserHandle)
} }
if c.ListID != 0 {
where_clauses = append(where_clauses, "by_user_id in (select user_id from list_users where list_id = ?)")
bind_values = append(bind_values, c.ListID)
}
// Since and until timestamps // Since and until timestamps
if c.SinceTimestamp.Unix() != 0 { if c.SinceTimestamp.Unix() != 0 {

View File

@ -73,6 +73,8 @@ create index if not exists index_list_users_list_id on list_users (list_id);
create index if not exists index_list_users_user_id on list_users (user_id); create index if not exists index_list_users_user_id on list_users (user_id);
insert into lists(rowid, name) values (1, "Offline Follows"); insert into lists(rowid, name) values (1, "Offline Follows");
insert into list_users(list_id, user_id) select 1, id from users where is_followed = 1; insert into list_users(list_id, user_id) select 1, id from users where is_followed = 1;
insert into lists(rowid, name) values (2, "Bronze Age");
insert into list_users(list_id, user_id) select 2, id from users where display_name like "%bronze age%";
create table tombstone_types (rowid integer primary key, create table tombstone_types (rowid integer primary key,