Commit a75d0485bc

a75d0485bcbf89d73d6db15af7d36b1c782c57f5

parent: 67d2c52b58

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:22 UTC

Web: reaction buttons under issue and merge request bodies and comments

Ref #291

Layout: unified · split

internal/httpd/issuecreate_test.go +2 −1
@@ -31,7 +31,8 @@ func TestIssuePageHasDiscussionHeading(t *testing.T) {
31 Notice string 31 Notice string
32 LabelColors map[string]template.CSS 32 LabelColors map[string]template.CSS
33 Draft *draft 33 Draft *draft
34 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil { 34 Reactions map[int64]reactionBar
35 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}, Reactions: map[int64]reactionBar{0: {}}}); err != nil {
35 t.Fatalf("render: %v", err) 36 t.Fatalf("render: %v", err)
36 } 37 }
37 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { 38 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
internal/httpd/mrpage_test.go +1
@@ -40,6 +40,7 @@ type mrPageData struct {
40 Base string 40 Base string
41 Draft *draft 41 Draft *draft
42 Layout diffLayout 42 Layout diffLayout
43 Reactions map[int64]reactionBar
43} 44}
44 45
45// A pruned head has no diff to show; the page must say the head is gone 46// A pruned head has no diff to show; the page must say the head is gone
internal/httpd/reactions.go added +84
@@ -0,0 +1,84 @@
1package httpd
2
3import (
4 "net/http"
5 "strings"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// reactionView is one reaction button (or count) under an item.
11type reactionView struct {
12 Name string
13 Emoji string
14 Count int
15 Me bool
16 Label string
17}
18
19// reactionBar is what the reactions partial renders for one item:
20// Comment is 0 for the issue or merge request body.
21type reactionBar struct {
22 Action string
23 Comment int64
24 Signed bool
25 Items []reactionView
26}
27
28// reactionBars builds the bars for a thread body (key 0) and its
29// comments. A signed-in viewer is offered every reaction; anyone else
30// sees only the ones somebody gave.
31func (s *Server) reactionBars(r *http.Request, noun string, threadID int64, comments []store.IssueComment, action string) map[int64]reactionBar {
32 viewer := s.webViewer(r)
33 counts, _ := s.st.ReactionCounts(noun, threadID, viewer.ID)
34 bar := func(id int64) reactionBar {
35 b := reactionBar{Action: action, Comment: id, Signed: viewer.ID != 0}
36 given := map[string]store.ReactionCount{}
37 for _, rc := range counts[id] {
38 given[rc.Reaction] = rc
39 }
40 for _, r := range store.Reactions {
41 rc := given[r.Name]
42 if rc.Count == 0 && !b.Signed {
43 continue
44 }
45 label := r.Name
46 if rc.Me {
47 label = "remove " + r.Name
48 }
49 b.Items = append(b.Items, reactionView{r.Name, r.Emoji, rc.Count, rc.Me, label})
50 }
51 return b
52 }
53 out := map[int64]reactionBar{0: bar(0)}
54 for _, c := range comments {
55 if c.Kind != "system" {
56 out[c.ID] = bar(c.ID)
57 }
58 }
59 return out
60}
61
62// reactArgs turns the form into the react command's arguments. The
63// button pressed is named add or remove and carries the reaction.
64func reactArgs(r *http.Request, noun string) []string {
65 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
66 args := []string{noun, "react", repo, r.PathValue("n")}
67 if c := strings.TrimSpace(r.FormValue("comment")); c != "" {
68 args = append(args, "--comment", c)
69 }
70 if v := r.FormValue("remove"); v != "" {
71 return append(args, "--remove", v)
72 }
73 return append(args, r.FormValue("add"))
74}
75
76func (s *Server) issueReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
77 _, msg, code := s.runControlCode(u, reactArgs(r, "issue"))
78 s.done(w, r, code, msg, s.issueRedirect)
79}
80
81func (s *Server) mrReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
82 _, msg, code := s.runControlCode(u, reactArgs(r, "mr"))
83 s.done(w, r, code, msg, s.mrRedirect)
84}
internal/httpd/reactions_test.go added +106
@@ -0,0 +1,106 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func reactServer(t *testing.T) (*Server, *store.Store, store.User, store.User) {
15 t.Helper()
16 st, err := store.Open(":memory:")
17 if err != nil {
18 t.Fatal(err)
19 }
20 t.Cleanup(func() { st.Close() })
21 if err := st.MigrateUp(); err != nil {
22 t.Fatal(err)
23 }
24 aid, _ := st.CreateUser("alice", false)
25 bid, _ := st.CreateUser("bob", false)
26 rid, err := st.CreateRepo("user", aid, "app", "public")
27 if err != nil {
28 t.Fatal(err)
29 }
30 n, _ := st.CreateIssue(rid, aid, "bug", "body", "md")
31 iss, _ := st.IssueByNumber(rid, n)
32 st.AddIssueComment(iss.ID, aid, "a comment", "md")
33 cfg := config.Default()
34 cfg.Web.Mode = "accounts"
35 return New(cfg, st, nil), st, store.User{ID: aid, Username: "alice"}, store.User{ID: bid, Username: "bob"}
36}
37
38func issueReq(method, target string, form url.Values) *http.Request {
39 var r *http.Request
40 if form != nil {
41 r = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
42 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
43 } else {
44 r = httptest.NewRequest(method, target, nil)
45 }
46 r.SetPathValue("owner", "alice")
47 r.SetPathValue("repo", "app")
48 r.SetPathValue("n", "1")
49 return r
50}
51
52func TestIssueReactionsOnPage(t *testing.T) {
53 s, st, _, bob := reactServer(t)
54
55 // Bob reacts to the body and to the comment through the form.
56 for _, form := range []url.Values{
57 {"add": {"+1"}},
58 {"add": {"rocket"}, "comment": {"1"}},
59 } {
60 rr := httptest.NewRecorder()
61 s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", form), bob)
62 if rr.Code != http.StatusSeeOther {
63 t.Fatalf("%v: status %d: %s", form, rr.Code, rr.Body)
64 }
65 }
66
67 // Signed in: a button for every reaction, his own marked pressed.
68 req := issueReq("GET", "/alice/app/issues/1", nil)
69 req.AddCookie(sessionCookieFor(t, s, st, bob.ID))
70 rr := httptest.NewRecorder()
71 s.issue(rr, req)
72 body := rr.Body.String()
73 if n := strings.Count(body, `class="react"`)+strings.Count(body, `class="react mine"`); n != 16 {
74 t.Errorf("%d reaction buttons, want 16 (8 on the body, 8 on the comment)", n)
75 }
76 if !strings.Contains(body, `name="remove" value="&#43;1" class="react mine" aria-pressed="true"`) {
77 t.Errorf("own reaction not marked:\n%s", body)
78 }
79 if !strings.Contains(body, `name="add" value="eyes"`) || !strings.Contains(body, `<input type="hidden" name="comment" value="1">`) {
80 t.Error("add buttons or comment field missing")
81 }
82
83 // Signed out: counts only, no forms or buttons.
84 rr = httptest.NewRecorder()
85 s.issue(rr, issueReq("GET", "/alice/app/issues/1", nil))
86 body = rr.Body.String()
87 if strings.Contains(body, `class="react"`) && strings.Contains(body, "<button type=\"submit\" name=\"add\"") {
88 t.Error("buttons shown to a signed-out viewer")
89 }
90 if strings.Contains(body, `/react"`) {
91 t.Error("react form shown to a signed-out viewer")
92 }
93 if n := strings.Count(body, `<span class="react"`); n != 2 {
94 t.Errorf("%d counts, want 2:\n%s", n, body)
95 }
96 if !strings.Contains(body, "👍 1") || !strings.Contains(body, "🚀 1") {
97 t.Error("counts missing")
98 }
99
100 // Pressing a pressed button takes it back.
101 rr = httptest.NewRecorder()
102 s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", url.Values{"remove": {"+1"}}), bob)
103 if got, _ := st.ReactionCounts("issue", 1, bob.ID); len(got[0]) != 0 {
104 t.Errorf("reaction not removed: %+v", got)
105 }
106}
internal/httpd/routes.go +4
@@ -160,6 +160,8 @@ func (s *Server) Routes() []Route {
160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, 160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, 161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
162 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, 162 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))},
163 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/react", Mutating: true,
164 Handler: s.checkOrigin(s.requireUser(s.issueReactSubmit))},
163 // Triage: each runs the matching issue command. 165 // Triage: each runs the matching issue command.
164 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true, 166 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true,
165 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))}, 167 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))},
@@ -206,6 +208,8 @@ func (s *Server) Routes() []Route {
206 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, 208 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))},
207 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, 209 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
208 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, 210 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))},
211 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/react", Mutating: true,
212 Handler: s.checkOrigin(s.requireUser(s.mrReactSubmit))},
209 // Review loop: each runs the matching mr command. 213 // Review loop: each runs the matching mr command.
210 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, 214 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true,
211 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, 215 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))},
internal/httpd/web.go +8 −3
@@ -1324,6 +1324,7 @@ func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1324 1324
1325// renderedComment pairs a comment with its rendered body for templates. 1325// renderedComment pairs a comment with its rendered body for templates.
1326type renderedComment struct { 1326type renderedComment struct {
1327 ID int64
1327 Author string 1328 Author string
1328 CreatedAt string 1329 CreatedAt string
1329 Kind string 1330 Kind string
@@ -1333,7 +1334,7 @@ type renderedComment struct {
1333func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment { 1334func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1334 var out []renderedComment 1335 var out []renderedComment
1335 for _, c := range cs { 1336 for _, c := range cs {
1336 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)}) 1337 out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)})
1337 } 1338 }
1338 return out 1339 return out
1339} 1340}
@@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
2024 } 2025 }
2025 // nil readable: the picker lists titles, never the progress counts. 2026 // nil readable: the picker lists titles, never the progress counts.
2026 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) 2027 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
2028 bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number))
2027 s.render(w, "issue.html", struct { 2029 s.render(w, "issue.html", struct {
2028 repoPage 2030 repoPage
2029 Issue store.Issue 2031 Issue store.Issue
@@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
2035 Notice string 2037 Notice string
2036 LabelColors map[string]template.CSS 2038 LabelColors map[string]template.CSS
2037 Draft *draft 2039 Draft *draft
2040 Reactions map[int64]reactionBar
2038 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), 2041 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
2039 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), 2042 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
2040 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d}) 2043 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars})
2041} 2044}
2042 2045
2043// canEditItem: the author or anyone with write access may edit. 2046// canEditItem: the author or anyone with write access may edit.
@@ -2328,6 +2331,7 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2328 } 2331 }
2329 d = s.draftFor(r, p.Repo, previewForm, "body", format) 2332 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2330 } 2333 }
2334 bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
2331 s.render(w, "mr.html", struct { 2335 s.render(w, "mr.html", struct {
2332 repoPage 2336 repoPage
2333 MR store.MR 2337 MR store.MR
@@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2360 LabelColors map[string]template.CSS 2364 LabelColors map[string]template.CSS
2361 Draft *draft 2365 Draft *draft
2362 Layout diffLayout 2366 Layout diffLayout
2367 Reactions map[int64]reactionBar
2363 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 2368 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2364 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 2369 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2365 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, 2370 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2366 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout}) 2371 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars})
2367} 2372}
2368 2373
2369// sourceGone reports whether an MR's source branch no longer exists: the 2374// sourceGone reports whether an MR's source branch no longer exists: the
internal/web/static/style.css +18
@@ -1917,3 +1917,21 @@ svg.icon { vertical-align: -0.125em; }
1917 /* the mark is a link home too, so it is a target like the rest */ 1917 /* the mark is a link home too, so it is a target like the rest */
1918 a.brand { width: var(--rail-hit); } 1918 a.brand { width: var(--rail-hit); }
1919} 1919}
1920
1921/* ---- reactions ---- */
1922.reactions { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0; padding: var(--sp-2) var(--sp-4); }
1923p.reactions { color: var(--muted); font-size: var(--fs-1); }
1924article.comment .reactions { border-top: 1px solid var(--faint); }
1925button.react, span.react {
1926 font: inherit;
1927 font-size: var(--fs-1);
1928 padding: 0 var(--sp-2);
1929 min-height: 24px;
1930 border: 1px solid var(--line);
1931 border-radius: var(--r-sm);
1932 background: transparent;
1933 color: var(--muted);
1934}
1935button.react { cursor: pointer; }
1936button.react:hover { background: var(--surface); }
1937button.react.mine { border-color: var(--accent); background: var(--surface); color: var(--fg); }
internal/web/templates/issue.html +3 −1
@@ -22,12 +22,14 @@
22{{if .BodyHTML}}<article class="comment"> 22{{if .BodyHTML}}<article class="comment">
23 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> 23 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
24 <div class="rendered">{{.BodyHTML}}</div> 24 <div class="rendered">{{.BodyHTML}}</div>
25</article>{{end}} 25 {{template "reactions" index .Reactions 0}}
26</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
26{{range .Comments}} 27{{range .Comments}}
27{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 28{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
28{{else}}<article class="comment"> 29{{else}}<article class="comment">
29 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> 30 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
30 <div class="rendered">{{.BodyHTML}}</div> 31 <div class="rendered">{{.BodyHTML}}</div>
32 {{template "reactions" index $.Reactions .ID}}
31</article>{{end}} 33</article>{{end}}
32{{end}} 34{{end}}
33{{if .Viewer}} 35{{if .Viewer}}
internal/web/templates/layout.html +6
@@ -182,6 +182,12 @@
182{{/* previewbtn is the Preview submit beside a markup form's own submit. 182{{/* previewbtn is the Preview submit beside a markup form's own submit.
183 It posts the form to its usual action, which renders the body and 183 It posts the form to its usual action, which renders the body and
184 hands the page back instead of writing (#235). */}} 184 hands the page back instead of writing (#235). */}}
185{{/* reactions is one item's reaction bar (reactionBar in httpd). A
186 signed-in viewer gets a button per reaction; anyone else the counts. */}}
187{{define "reactions"}}{{if .Items}}{{if .Signed}}<form method="post" action="{{.Action}}" class="reactions">{{if .Comment}}<input type="hidden" name="comment" value="{{.Comment}}">{{end}}
188{{range .Items}}<button type="submit" name="{{if .Me}}remove{{else}}add{{end}}" value="{{.Name}}" class="react{{if .Me}} mine{{end}}" aria-pressed="{{.Me}}" aria-label="{{.Label}}">{{.Emoji}}{{if .Count}} <span>{{.Count}}</span>{{end}}</button>
189{{end}}</form>{{else}}<p class="reactions">{{range .Items}}<span class="react" aria-label="{{.Label}}">{{.Emoji}} {{.Count}}</span> {{end}}</p>{{end}}{{end}}{{end}}
190
185{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}} 191{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}}
186 192
187{{/* previewblock shows a draft as it will read. The argument is the 193{{/* previewblock shows a draft as it will read. The argument is the
internal/web/templates/mr.html +3 −1
@@ -37,12 +37,14 @@
37{{if .BodyHTML}}<article class="comment"> 37{{if .BodyHTML}}<article class="comment">
38 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> 38 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
39 <div class="rendered">{{.BodyHTML}}</div> 39 <div class="rendered">{{.BodyHTML}}</div>
40</article>{{end}} 40 {{template "reactions" index .Reactions 0}}
41</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
41{{range .Comments}} 42{{range .Comments}}
42{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 43{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
43{{else}}<article class="comment"> 44{{else}}<article class="comment">
44 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> 45 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
45 <div class="rendered">{{.BodyHTML}}</div> 46 <div class="rendered">{{.BodyHTML}}</div>
47 {{template "reactions" index $.Reactions .ID}}
46</article>{{end}} 48</article>{{end}}
47{{end}} 49{{end}}
48{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> 50{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>