feat(profile): follow people and list followers and following
7 files changed, +130 −2
- forge/flow_test.go modified +32 −0
- forge/follow.go added +69 −0
- forge/store.go modified +2 −2
- forge/web.go modified +10 −0
- web/static/style.css modified +6 −0
- web/templates/follows.tmpl added +9 −0
- web/templates/user.tmpl modified +2 −0
forge/flow_test.go modified
| @@ -516,3 +516,35 @@ func TestPostThrottle(t *testing.T) { | ||
| 516 | 516 | t.Error("anonymous posts share the user bucket") |
| 517 | 517 | } |
| 518 | 518 | } |
| 519 | + | |
| 520 | +func TestFollows(t *testing.T) { | |
| 521 | + s, h := testServer(t) | |
| 522 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 523 | + bob, _ := s.store.CreateUser("bob", "correct horse") | |
| 524 | + org, _ := s.store.CreateOrg("crew") | |
| 525 | + c, _ := s.store.CreateSession(alice.ID) | |
| 526 | + if w := post(h, "/~bob/follow", nil, c); w.Code != http.StatusSeeOther { | |
| 527 | + t.Fatalf("follow gave %d", w.Code) | |
| 528 | + } | |
| 529 | + post(h, "/~alice/follow", nil, c) | |
| 530 | + post(h, "/~crew/follow", nil, c) | |
| 531 | + if followers, following := s.store.FollowCounts(bob.ID); followers != 1 || following != 0 { | |
| 532 | + t.Errorf("bob has %d followers, %d following", followers, following) | |
| 533 | + } | |
| 534 | + if s.store.Follows(alice.ID, alice.ID) || s.store.Follows(alice.ID, org.ID) { | |
| 535 | + t.Error("followed self or an organization") | |
| 536 | + } | |
| 537 | + if body := get(h, "/~bob/followers", "").Body.String(); !strings.Contains(body, "~alice") { | |
| 538 | + t.Error("follower not listed") | |
| 539 | + } | |
| 540 | + if body := get(h, "/~bob", c).Body.String(); !strings.Contains(body, "Unfollow") { | |
| 541 | + t.Error("profile does not offer unfollow") | |
| 542 | + } | |
| 543 | + post(h, "/~bob/follow", nil, c) | |
| 544 | + if s.store.Follows(alice.ID, bob.ID) { | |
| 545 | + t.Error("second post did not unfollow") | |
| 546 | + } | |
| 547 | + if w := post(h, "/~bob/follow", nil, ""); w.Code == http.StatusSeeOther && s.store.Follows(0, bob.ID) { | |
| 548 | + t.Error("anonymous follow stored") | |
| 549 | + } | |
| 550 | +} | |
forge/follow.go added
| @@ -0,0 +1,69 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +const followSchema = ` | |
| 9 | +create table if not exists follows ( | |
| 10 | + follower integer not null references users(id) on delete cascade, | |
| 11 | + followee integer not null references users(id) on delete cascade, | |
| 12 | + created integer not null, | |
| 13 | + primary key (follower, followee) | |
| 14 | +); | |
| 15 | +create index if not exists follows_followee on follows(followee, created); | |
| 16 | +` | |
| 17 | + | |
| 18 | +func (s *Store) Follow(follower, followee int64, on bool) { | |
| 19 | + if on { | |
| 20 | + s.db.Exec(`insert or ignore into follows(follower, followee, created) values (?, ?, ?)`, follower, followee, time.Now().Unix()) | |
| 21 | + } else { | |
| 22 | + s.db.Exec(`delete from follows where follower = ? and followee = ?`, follower, followee) | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +func (s *Store) Follows(follower, followee int64) bool { | |
| 27 | + var n int | |
| 28 | + s.db.QueryRow(`select count(*) from follows where follower = ? and followee = ?`, follower, followee).Scan(&n) | |
| 29 | + return n > 0 | |
| 30 | +} | |
| 31 | + | |
| 32 | +func (s *Store) FollowCounts(id int64) (followers, following int) { | |
| 33 | + s.db.QueryRow(`select | |
| 34 | + (select count(*) from follows f join users u on u.id = f.follower where f.followee = ?1 and u.banned = 0), | |
| 35 | + (select count(*) from follows f join users u on u.id = f.followee where f.follower = ?1 and u.banned = 0)`, id).Scan(&followers, &following) | |
| 36 | + return | |
| 37 | +} | |
| 38 | + | |
| 39 | +func (s *Store) Followers(id int64) []User { | |
| 40 | + return s.userList(`select u.id, u.name, u.created from follows f join users u on u.id = f.follower where f.followee = ? and u.banned = 0 order by f.created desc`, id) | |
| 41 | +} | |
| 42 | + | |
| 43 | +func (s *Store) Following(id int64) []User { | |
| 44 | + return s.userList(`select u.id, u.name, u.created from follows f join users u on u.id = f.followee where f.follower = ? and u.banned = 0 order by f.created desc`, id) | |
| 45 | +} | |
| 46 | + | |
| 47 | +func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) { | |
| 48 | + u := s.require(w, r) | |
| 49 | + if u == nil { | |
| 50 | + return | |
| 51 | + } | |
| 52 | + if u.ID != owner.ID && !s.store.IsOrg(owner.ID) { | |
| 53 | + s.store.Follow(u.ID, owner.ID, !s.store.Follows(u.ID, owner.ID)) | |
| 54 | + } | |
| 55 | + http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther) | |
| 56 | +} | |
| 57 | + | |
| 58 | +func (s *Server) follows(w http.ResponseWriter, r *http.Request, owner User, viewer *User, kind string) { | |
| 59 | + list := s.store.Followers(owner.ID) | |
| 60 | + if kind == "following" { | |
| 61 | + list = s.store.Following(owner.ID) | |
| 62 | + } | |
| 63 | + people := make([]userHit, 0, len(list)) | |
| 64 | + for _, p := range list { | |
| 65 | + people = append(people, userHit{User: p, Profile: s.store.Profile(p.ID)}) | |
| 66 | + } | |
| 67 | + followers, following := s.store.FollowCounts(owner.ID) | |
| 68 | + s.render(w, r, "follows", page{Title: "~" + owner.Name + " " + kind, User: viewer, Data: map[string]any{"Owner": owner, "Kind": kind, "People": people, "Followers": followers, "Following": following}}) | |
| 69 | +} | |
forge/store.go modified
| @@ -277,7 +277,7 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 277 | 277 | if _, err := db.Exec(schema); err != nil { |
| 278 | 278 | return nil, err |
| 279 | 279 | } |
| 280 | - for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema} { | |
| 280 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema} { | |
| 281 | 281 | if _, err := db.Exec(ddl); err != nil { |
| 282 | 282 | return nil, err |
| 283 | 283 | } |
| @@ -1122,7 +1122,7 @@ func validEmail(email string) bool { | ||
| 1122 | 1122 | const maxPins = 6 |
| 1123 | 1123 | |
| 1124 | 1124 | func reservedRepo(name string) bool { |
| 1125 | - return name == "avatar" || name == "stars" || name == "feed" || name == "repos" | |
| 1125 | + return name == "avatar" || name == "stars" || name == "feed" || name == "repos" || name == "follow" || name == "followers" || name == "following" | |
| 1126 | 1126 | } |
| 1127 | 1127 | |
| 1128 | 1128 | func (s *Store) Pins(owner int64) map[int64]int { |
forge/web.go modified
| @@ -919,6 +919,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 919 | 919 | orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)}) |
| 920 | 920 | } |
| 921 | 921 | data["Orgs"] = orgs |
| 922 | + data["Followers"], data["Following"] = s.store.FollowCounts(owner.ID) | |
| 923 | + data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID) | |
| 922 | 924 | intro := s.profileReadme(owner) |
| 923 | 925 | data["Intro"] = intro |
| 924 | 926 | if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name { |
| @@ -942,6 +944,14 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 942 | 944 | s.orgSettings(w, r, owner, name) |
| 943 | 945 | return |
| 944 | 946 | } |
| 947 | + if (name == "followers" || name == "following") && len(parts) == 2 && r.Method == "GET" && !s.store.IsOrg(owner.ID) { | |
| 948 | + s.follows(w, r, owner, user, name) | |
| 949 | + return | |
| 950 | + } | |
| 951 | + if name == "follow" && len(parts) == 2 && r.Method == "POST" { | |
| 952 | + s.follow(w, r, owner) | |
| 953 | + return | |
| 954 | + } | |
| 945 | 955 | if name == "repos" && len(parts) == 2 && r.Method == "GET" { |
| 946 | 956 | s.repos(w, r, owner, user) |
| 947 | 957 | return |
web/static/style.css modified
| @@ -382,6 +382,12 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 382 | 382 | .facts a{color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| 383 | 383 | .facts svg{flex:none;width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round} |
| 384 | 384 | .ownactions{border-top:1px solid var(--line);padding-top:.75rem} |
| 385 | +.followform{margin:0 0 .8rem} | |
| 386 | +.followform .btn{width:100%} | |
| 387 | +.facts .followcount{gap:.45rem} | |
| 388 | +.facts .followcount a{color:var(--fg2)} | |
| 389 | +.facts .followcount a:hover{color:var(--link);text-decoration:none} | |
| 390 | +.facts .followcount .sep{margin:0 .15rem} | |
| 385 | 391 | .profile .bio.readme,.profile .bio.readme p{font-family:inherit;font-size:.98rem;line-height:1.5} |
| 386 | 392 | .navsearch{flex:0 1 20rem;margin-left:.5rem} |
| 387 | 393 | .navsearch input{width:100%;padding:.2rem .55rem;font-size:.9rem} |
web/templates/follows.tmpl added
| @@ -0,0 +1,9 @@ | ||
| 1 | +{{define "content"}} | |
| 2 | +{{$o := .Data.Owner}} | |
| 3 | +<div class="userhead"> | |
| 4 | +<h2>~{{$o.Name}}</h2> | |
| 5 | +<nav class="tabs small"><a href="/~{{$o.Name}}">about</a><a href="/~{{$o.Name}}/repos">repos</a><a href="/~{{$o.Name}}/stars">stars</a><a href="/~{{$o.Name}}/followers"{{if eq .Data.Kind "followers"}} class="active"{{end}}>followers <span class="count">{{.Data.Followers}}</span></a><a href="/~{{$o.Name}}/following"{{if eq .Data.Kind "following"}} class="active"{{end}}>following <span class="count">{{.Data.Following}}</span></a></nav> | |
| 6 | +</div> | |
| 7 | +{{range .Data.People}}<div class="hit user"><a href="/~{{.Name}}" class="hitavatar"><img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt=""></a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>Joined {{.Created.Format "January 2006"}}</span></p></div></div> | |
| 8 | +{{else}}<p class="muted">{{if eq .Data.Kind "followers"}}Nobody follows ~{{$o.Name}} yet.{{else}}~{{$o.Name}} does not follow anyone yet.{{end}}</p>{{end}} | |
| 9 | +{{end}} | |
web/templates/user.tmpl modified
| @@ -10,7 +10,9 @@ | ||
| 10 | 10 | <img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt=""> |
| 11 | 11 | <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div> |
| 12 | 12 | {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}} |
| 13 | +{{if and .User (not $own)}}<form method="post" action="/~{{.Data.Owner.Name}}/follow" class="followform"><button class="btn block{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form>{{end}} | |
| 13 | 14 | <ul class="facts"> |
| 15 | +<li class="followcount"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5.5" r="2.4"/><path d="M1.8 13.5c.5-2.4 2.2-3.7 4.2-3.7s3.7 1.3 4.2 3.7"/><path d="M10.4 3.3a2.4 2.4 0 0 1 0 4.4M12 9.9c1.2.5 2 1.7 2.2 3.6"/></svg><a href="/~{{.Data.Owner.Name}}/followers">{{.Data.Followers}} follower{{if ne .Data.Followers 1}}s{{end}}</a><span class="sep">·</span><a href="/~{{.Data.Owner.Name}}/following">{{.Data.Following}} following</a></li> | |
| 14 | 16 | {{if $p.Location}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 15s5-4.6 5-8.5A5 5 0 0 0 3 6.5C3 10.4 8 15 8 15z"/><circle cx="8" cy="6.5" r="1.8"/></svg>{{$p.Location}}</li>{{end}} |
| 15 | 17 | {{if $p.Workplace}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="5" width="12" height="9" rx="1.5"/><path d="M6 5V3.5A1 1 0 0 1 7 2.5h2a1 1 0 0 1 1 1V5"/></svg>{{with .Data.Workplace}}<a href="/~{{.}}">{{$p.Workplace}}</a>{{else}}{{$p.Workplace}}{{end}}</li>{{end}} |
| 16 | 18 | {{range $p.Links}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6.8 9.2a3 3 0 0 0 4.2 0l2.2-2.2a3 3 0 0 0-4.2-4.2l-.9.9"/><path d="M9.2 6.8a3 3 0 0 0-4.2 0L2.8 9a3 3 0 0 0 4.2 4.2l.9-.9"/></svg><a href="{{.}}" rel="nofollow me">{{bare .}}</a></li>{{end}} |