~gitpocket/gitpocket

3

feat(profile): follow people and list followers and following

commit
e69795108fe9b7b54fc7bbabb7431a6e66bdec1d browse tree
parent
f44e2cbd66df51dc5a71b29e277f9b1042297176
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-13 17:35 UTC

7 files changed, +130 −2

forge/flow_test.go modified

@@ -516,3 +516,35 @@ func TestPostThrottle(t *testing.T) {
516516 t.Error("anonymous posts share the user bucket")
517517 }
518518 }
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) {
277277 if _, err := db.Exec(schema); err != nil {
278278 return nil, err
279279 }
280- for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema} {
280+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema} {
281281 if _, err := db.Exec(ddl); err != nil {
282282 return nil, err
283283 }
@@ -1122,7 +1122,7 @@ func validEmail(email string) bool {
11221122 const maxPins = 6
11231123
11241124 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"
11261126 }
11271127
11281128 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) {
919919 orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)})
920920 }
921921 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)
922924 intro := s.profileReadme(owner)
923925 data["Intro"] = intro
924926 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) {
942944 s.orgSettings(w, r, owner, name)
943945 return
944946 }
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+ }
945955 if name == "repos" && len(parts) == 2 && r.Method == "GET" {
946956 s.repos(w, r, owner, user)
947957 return

web/static/style.css modified

@@ -382,6 +382,12 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
382382 .facts a{color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
383383 .facts svg{flex:none;width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
384384 .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}
385391 .profile .bio.readme,.profile .bio.readme p{font-family:inherit;font-size:.98rem;line-height:1.5}
386392 .navsearch{flex:0 1 20rem;margin-left:.5rem}
387393 .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 @@
1010 <img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt="">
1111 <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div>
1212 {{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}}
1314 <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>
1416 {{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}}
1517 {{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}}
1618 {{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}}