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) {
3131 Notice string
3232 LabelColors map[string]template.CSS
3333 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 {
3536 t.Fatalf("render: %v", err)
3637 }
3738 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
internal/httpd/mrpage_test.go +1
@@ -40,6 +40,7 @@ type mrPageData struct {
4040 Base string
4141 Draft *draft
4242 Layout diffLayout
43 Reactions map[int64]reactionBar
4344}
4445
4546// 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 {
160160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
161161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
162162 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))},
163165 // Triage: each runs the matching issue command.
164166 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true,
165167 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))},
@@ -206,6 +208,8 @@ func (s *Server) Routes() []Route {
206208 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))},
207209 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
208210 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))},
209213 // Review loop: each runs the matching mr command.
210214 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true,
211215 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 {
13241324
13251325// renderedComment pairs a comment with its rendered body for templates.
13261326type renderedComment struct {
1327 ID int64
13271328 Author string
13281329 CreatedAt string
13291330 Kind string
@@ -1333,7 +1334,7 @@ type renderedComment struct {
13331334func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
13341335 var out []renderedComment
13351336 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)})
13371338 }
13381339 return out
13391340}
@@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
20242025 }
20252026 // nil readable: the picker lists titles, never the progress counts.
20262027 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))
20272029 s.render(w, "issue.html", struct {
20282030 repoPage
20292031 Issue store.Issue
@@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
20352037 Notice string
20362038 LabelColors map[string]template.CSS
20372039 Draft *draft
2040 Reactions map[int64]reactionBar
20382041 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
20392042 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})
20412044}
20422045
20432046// 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
23282331 }
23292332 d = s.draftFor(r, p.Repo, previewForm, "body", format)
23302333 }
2334 bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
23312335 s.render(w, "mr.html", struct {
23322336 repoPage
23332337 MR store.MR
@@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
23602364 LabelColors map[string]template.CSS
23612365 Draft *draft
23622366 Layout diffLayout
2367 Reactions map[int64]reactionBar
23632368 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
23642369 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
23652370 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})
23672372}
23682373
23692374// 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; }
19171917 /* the mark is a link home too, so it is a target like the rest */
19181918 a.brand { width: var(--rail-hit); }
19191919}
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 @@
2222{{if .BodyHTML}}<article class="comment">
2323 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
2424 <div class="rendered">{{.BodyHTML}}</div>
25</article>{{end}}
25 {{template "reactions" index .Reactions 0}}
26</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
2627{{range .Comments}}
2728{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
2829{{else}}<article class="comment">
2930 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
3031 <div class="rendered">{{.BodyHTML}}</div>
32 {{template "reactions" index $.Reactions .ID}}
3133</article>{{end}}
3234{{end}}
3335{{if .Viewer}}
internal/web/templates/layout.html +6
@@ -182,6 +182,12 @@
182182{{/* previewbtn is the Preview submit beside a markup form's own submit.
183183 It posts the form to its usual action, which renders the body and
184184 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
185191{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}}
186192
187193{{/* previewblock shows a draft as it will read. The argument is the
internal/web/templates/mr.html +3 −1
@@ -37,12 +37,14 @@
3737{{if .BodyHTML}}<article class="comment">
3838 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
3939 <div class="rendered">{{.BodyHTML}}</div>
40</article>{{end}}
40 {{template "reactions" index .Reactions 0}}
41</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
4142{{range .Comments}}
4243{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
4344{{else}}<article class="comment">
4445 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
4546 <div class="rendered">{{.BodyHTML}}</div>
47 {{template "reactions" index $.Reactions .ID}}
4648</article>{{end}}
4749{{end}}
4850{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>