~gitpocket/gitpocket

1

feat(inbox): notifications

commit
381360f5ede020e5ff71e7fe9111c4a1b0d3cdd6 browse tree
parent
20596d989af6d5379c73ccc5d6d896426228c226
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-05 22:11 UTC

11 files changed, +303 −18

admin.go modified

@@ -79,8 +79,8 @@ func (s *Store) IsAdmin(id int64) bool {
7979
8080 var themes = []string{"", "light", "dark"}
8181
82-func (s *Store) Viewer(id int64) (admin bool, theme string) {
83- s.db.QueryRow(`select admin and not banned, theme from users where id = ?`, id).Scan(&admin, &theme)
82+func (s *Store) Viewer(id int64) (admin bool, theme string, unread int) {
83+ s.db.QueryRow(`select admin and not banned, theme, (select count(*) from notifications where user = ?1 and unread = 1) from users where id = ?1`, id).Scan(&admin, &theme, &unread)
8484 return
8585 }
8686

avatar.go modified

@@ -52,28 +52,69 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) {
5252 return out.Bytes(), nil
5353 }
5454
55+var blocks = [][][2]float64{
56+ {{0, 0}, {1, 0}, {1, 1}, {0, 1}},
57+ {{0, 0}, {1, 0}, {0, 1}},
58+ {{0, 0}, {1, .5}, {0, 1}},
59+ {{.5, 0}, {1, .5}, {.5, 1}, {0, .5}},
60+ {{.25, .25}, {.75, .25}, {.75, .75}, {.25, .75}},
61+ {{0, 0}, {1, 0}, {.5, .5}},
62+ {{.5, 0}, {1, 1}, {0, 1}},
63+ {{0, 0}, {1, 0}, {1, .5}, {0, .5}},
64+ {{0, 0}, {.5, 0}, {1, 1}, {.5, 1}},
65+ {{0, .5}, {.5, 0}, {1, .5}, {1, 1}, {0, 1}},
66+}
67+
68+var centres = []int{0, 3, 4}
69+
5570 func identicon(seed string) []byte {
5671 sum := sha256.Sum256([]byte(seed))
5772 h := float64(uint16(sum[28])<<8|uint16(sum[29])) / 65536
58- fg := hsl(h, 0.45+float64(sum[30])/255*0.2, 0.5+float64(sum[31])/255*0.12)
59- const cell, pad = 44, 18
73+ fg := hsl(h, 0.5+float64(sum[30])/255*0.2, 0.42+float64(sum[31])/255*0.12)
74+ const pad, cell = 14, 76
6075 img := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
61- draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{240, 240, 240, 255}}, image.Point{}, draw.Src)
62- for i := 0; i < 15; i++ {
63- if sum[i]&1 != 0 {
64- continue
65- }
66- row, col := i%5, i/5
67- for _, c := range []int{col, 4 - col} {
68- r := image.Rect(pad+c*cell, pad+row*cell, pad+(c+1)*cell, pad+(row+1)*cell)
69- draw.Draw(img, r, &image.Uniform{fg}, image.Point{}, draw.Src)
76+ draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{255, 255, 255, 255}}, image.Point{}, draw.Src)
77+ corner, side := int(sum[0])%len(blocks), int(sum[1])%len(blocks)
78+ cornerTurn, sideTurn := int(sum[2])%4, int(sum[3])%4
79+ centre := centres[int(sum[4])%len(centres)]
80+ invert := sum[5]&1 == 1
81+ place := func(shape, col, row, turn int, flip bool) {
82+ poly := blocks[shape]
83+ for y := 0; y < cell; y++ {
84+ for x := 0; x < cell; x++ {
85+ u, v := (float64(x)+.5)/cell, (float64(y)+.5)/cell
86+ for i := 0; i < turn; i++ {
87+ u, v = v, 1-u
88+ }
89+ if inside(poly, u, v) != flip {
90+ img.SetRGBA(pad+col*cell+x, pad+row*cell+y, fg)
91+ }
92+ }
7093 }
7194 }
95+ for i, p := range [][2]int{{0, 0}, {2, 0}, {2, 2}, {0, 2}} {
96+ place(corner, p[0], p[1], (cornerTurn+i)%4, false)
97+ }
98+ for i, p := range [][2]int{{1, 0}, {2, 1}, {1, 2}, {0, 1}} {
99+ place(side, p[0], p[1], (sideTurn+i)%4, invert)
100+ }
101+ place(centre, 1, 1, 0, false)
72102 var out bytes.Buffer
73103 png.Encode(&out, img)
74104 return out.Bytes()
75105 }
76106
107+func inside(poly [][2]float64, x, y float64) bool {
108+ in := false
109+ for i, j := 0, len(poly)-1; i < len(poly); j, i = i, i+1 {
110+ a, b := poly[i], poly[j]
111+ if (a[1] > y) != (b[1] > y) && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] {
112+ in = !in
113+ }
114+ }
115+ return in
116+}
117+
77118 func hsl(h, s, l float64) color.RGBA {
78119 q := l + s - l*s
79120 if l < 0.5 {

flow_test.go modified

@@ -3,6 +3,7 @@ package main
33 import (
44 "bytes"
55 "encoding/json"
6+ "fmt"
67 "image"
78 "image/png"
89 "net/http"
@@ -433,3 +434,47 @@ func TestAttachments(t *testing.T) {
433434 t.Errorf("preview gave %q", body)
434435 }
435436 }
437+
438+func TestNotifications(t *testing.T) {
439+ s, h := testServer(t)
440+ alice, _ := s.store.CreateUser("alice", "correct horse")
441+ bob, _ := s.store.CreateUser("bob", "correct horse")
442+ carol, _ := s.store.CreateUser("carol", "correct horse")
443+ repo, _ := s.store.CreateRepo(alice, "proj", "", Public, "")
444+ repo, _ = s.store.RepoByID(repo.ID)
445+ issue, err := s.store.CreateIssue(repo, bob, "Crash on start", "")
446+ if err != nil {
447+ t.Fatal(err)
448+ }
449+ watch := func() []int64 { return s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID) }
450+ s.store.Notify(watch(), bob, repo, "issue", issue.Number, issue.Title, "opened")
451+ ca, _ := s.store.CreateSession(alice.ID)
452+ cb, _ := s.store.CreateSession(bob.ID)
453+ cc, _ := s.store.CreateSession(carol.ID)
454+ if body := get(h, "/notifications", ca).Body.String(); !strings.Contains(body, "~bob opened this") || !strings.Contains(body, `class="badge">1<`) {
455+ t.Fatalf("owner not notified:\n%s", body)
456+ }
457+ if strings.Contains(get(h, "/", cb).Body.String(), `class="badge"`) {
458+ t.Error("actor notified about their own action")
459+ }
460+ s.store.AddIssueComment(issue.ID, carol, "same here")
461+ s.store.Notify(watch(), carol, repo, "issue", issue.Number, issue.Title, "commented on")
462+ if n := s.store.Notifications(alice.ID); len(n) != 1 || n[0].Actor != "carol" || !n[0].Unread {
463+ t.Errorf("thread not collapsed into one entry: %+v", n)
464+ }
465+ s.store.Notify(watch(), alice, repo, "issue", issue.Number, issue.Title, "closed")
466+ if n := s.store.Notifications(carol.ID); len(n) != 1 || n[0].Verb != "closed" {
467+ t.Errorf("participant not notified: %+v", n)
468+ }
469+ get(h, fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number), cb)
470+ if n := s.store.Notifications(bob.ID); len(n) != 1 || n[0].Unread {
471+ t.Errorf("viewing the issue left it unread: %+v", n)
472+ }
473+ if w := post(h, "/notifications", url.Values{}, cc); w.Code != http.StatusSeeOther || s.store.Notifications(carol.ID)[0].Unread {
474+ t.Error("mark all as read did nothing")
475+ }
476+ s.store.UpdateRepo(repo.ID, "", Private, "")
477+ if n := s.store.Notifications(carol.ID); len(n) != 0 {
478+ t.Errorf("private repository leaked through notifications: %+v", n)
479+ }
480+}

inbox.go added

@@ -0,0 +1,155 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+const inboxSchema = `
12+create table if not exists notifications (
13+ id integer primary key,
14+ user integer not null references users(id) on delete cascade,
15+ repo integer not null references repos(id) on delete cascade,
16+ kind text not null,
17+ number integer not null,
18+ title text not null,
19+ actor integer not null references users(id) on delete cascade,
20+ verb text not null,
21+ created integer not null,
22+ unread integer not null default 1,
23+ unique(user, repo, kind, number)
24+);
25+`
26+
27+type Notification struct {
28+ ID int64
29+ Repo Repo
30+ Kind string
31+ Number int64
32+ Title string
33+ Actor string
34+ Verb string
35+ Created time.Time
36+ Unread bool
37+}
38+
39+func (n Notification) Link() string {
40+ return fmt.Sprintf("/%s/%ss/%d", n.Repo.Path(), n.Kind, n.Number)
41+}
42+
43+func (s *Store) Watchers(repo Repo, kind string, item, author int64) []int64 {
44+ ids := []int64{author}
45+ if s.IsOrg(repo.Owner.ID) {
46+ for _, m := range s.Members(repo.Owner.ID) {
47+ ids = append(ids, m.ID)
48+ }
49+ } else {
50+ ids = append(ids, repo.Owner.ID)
51+ }
52+ q := `select author from comments where pull = ?1 union select author from reviews where pull = ?1 union select author from prreviews where pull = ?1`
53+ if kind == "issue" {
54+ q = `select author from issue_comments where issue = ?1`
55+ }
56+ rows, err := s.db.Query(q, item)
57+ if err != nil {
58+ return ids
59+ }
60+ defer rows.Close()
61+ for rows.Next() {
62+ var id int64
63+ if rows.Scan(&id) == nil {
64+ ids = append(ids, id)
65+ }
66+ }
67+ return ids
68+}
69+
70+func (s *Store) Notify(users []int64, actor User, repo Repo, kind string, number int64, title, verb string) {
71+ seen := map[int64]bool{actor.ID: true}
72+ now := time.Now().Unix()
73+ for _, id := range users {
74+ if seen[id] {
75+ continue
76+ }
77+ seen[id] = true
78+ if repo.Private && !s.CanRead(repo, &User{ID: id}) {
79+ continue
80+ }
81+ s.db.Exec(`insert into notifications(user, repo, kind, number, title, actor, verb, created) values (?, ?, ?, ?, ?, ?, ?, ?)
82+ on conflict(user, repo, kind, number) do update set title = excluded.title, actor = excluded.actor, verb = excluded.verb, created = excluded.created, unread = 1`,
83+ id, repo.ID, kind, number, title, actor.ID, verb, now)
84+ }
85+}
86+
87+func (s *Store) Notifications(user int64) []Notification {
88+ s.db.Exec(`delete from notifications where user = ? and unread = 0 and created < ?`, user, time.Now().AddDate(0, 0, -90).Unix())
89+ rows, err := s.db.Query(`select n.id, n.repo, n.kind, n.number, n.title, a.name, n.verb, n.created, n.unread from notifications n join users a on a.id = n.actor where n.user = ? order by n.unread desc, n.created desc limit 100`, user)
90+ if err != nil {
91+ return nil
92+ }
93+ var out []Notification
94+ var repos []int64
95+ for rows.Next() {
96+ var n Notification
97+ var created int64
98+ if rows.Scan(&n.ID, &n.Repo.ID, &n.Kind, &n.Number, &n.Title, &n.Actor, &n.Verb, &created, &n.Unread) == nil {
99+ n.Created = time.Unix(created, 0)
100+ out = append(out, n)
101+ repos = append(repos, n.Repo.ID)
102+ }
103+ }
104+ rows.Close()
105+ shown := out[:0]
106+ for i, n := range out {
107+ repo, err := s.RepoByID(repos[i])
108+ if err != nil || !s.CanRead(repo, &User{ID: user}) {
109+ continue
110+ }
111+ n.Repo = repo
112+ shown = append(shown, n)
113+ }
114+ return shown
115+}
116+
117+func (s *Store) ReadThread(user, repo int64, kind string, number int64) {
118+ s.db.Exec(`update notifications set unread = 0 where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number)
119+}
120+
121+func (s *Server) inbox(w http.ResponseWriter, r *http.Request) {
122+ u := s.require(w, r)
123+ if u == nil {
124+ return
125+ }
126+ if r.Method == "POST" {
127+ s.store.db.Exec(`update notifications set unread = 0 where user = ?`, u.ID)
128+ http.Redirect(w, r, "/notifications", http.StatusSeeOther)
129+ return
130+ }
131+ s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID)}})
132+}
133+
134+func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) {
135+ u := s.require(w, r)
136+ if u == nil {
137+ return
138+ }
139+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
140+ for _, n := range s.store.Notifications(u.ID) {
141+ if n.ID == id {
142+ s.store.ReadThread(u.ID, n.Repo.ID, n.Kind, n.Number)
143+ http.Redirect(w, r, n.Link(), http.StatusSeeOther)
144+ return
145+ }
146+ }
147+ http.Redirect(w, r, "/notifications", http.StatusSeeOther)
148+}
149+
150+func shortVerb(verb string) string {
151+ if strings.HasPrefix(verb, "reviewed ") {
152+ return "reviewed"
153+ }
154+ return verb
155+}

issues.go modified

@@ -192,6 +192,7 @@ func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo)
192192 s.render(w, r, "newissue", page{Title: "New issue", Error: err.Error(), Data: map[string]any{"Page": rp, "IssueTitle": r.FormValue("title"), "Body": r.FormValue("body")}})
193193 return
194194 }
195+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "opened")
195196 if repo.Owner.ID != user.ID {
196197 s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title),
197198 fmt.Sprintf("~%s opened an issue on %s.\n\n%s\n\nhttps://%s/%s/issues/%d\n", user.Name, repo.Path(), issue.Body, s.host, repo.Path(), issue.Number))
@@ -215,6 +216,9 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
215216 }
216217 user := rp.User
217218 canManage := user != nil && (rp.Owner || user.ID == issue.Author.ID)
219+ if user != nil && r.Method == "GET" {
220+ s.store.ReadThread(user.ID, repo.ID, "issue", issue.Number)
221+ }
218222 back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number)
219223 if r.Method == "POST" {
220224 if user == nil {
@@ -233,6 +237,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
233237 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
234238 return
235239 }
240+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "commented on")
236241 for _, id := range []int64{issue.Author.ID, repo.Owner.ID} {
237242 if id != user.ID {
238243 s.notify(s.store.VerifiedEmail(id), subject, fmt.Sprintf("~%s commented on issue #%d.\n\n%s\n\n%s\n", user.Name, issue.Number, r.FormValue("body"), link))
@@ -244,10 +249,12 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
244249 case "close":
245250 if canManage {
246251 s.store.SetIssueState(issue.ID, "closed")
252+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "closed")
247253 }
248254 case "reopen":
249255 if canManage {
250256 s.store.SetIssueState(issue.ID, "open")
257+ s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "reopened")
251258 }
252259 default:
253260 s.fail(w, r, 404, "Page not found")

pull.go modified

@@ -528,6 +528,7 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
528528 }
529529 g.RecordRevision(pull)
530530 url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, repo.Path(), pull.Number)
531+ s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "opened")
531532 if repo.Owner.ID != user.ID {
532533 s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title),
533534 fmt.Sprintf("~%s opened a pull request on %s.\n\n%s\n\n%s\n", user.Name, repo.Path(), pull.Body, url))
@@ -561,6 +562,9 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
561562 }
562563 isOwner := s.store.CanWrite(repo, user)
563564 isAuthor := user != nil && user.ID == pull.Author.ID
565+ if user != nil && r.Method == "GET" {
566+ s.store.ReadThread(user.ID, repo.ID, "pull", pull.Number)
567+ }
564568 if r.Method == "POST" {
565569 if user == nil {
566570 http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -577,10 +581,12 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
577581 case "close":
578582 if (isOwner || isAuthor) && pull.State == "open" {
579583 s.store.SetPullState(pull.ID, "closed", "")
584+ s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "closed")
580585 }
581586 case "reopen":
582587 if (isOwner || isAuthor) && pull.State == "closed" {
583588 s.store.SetPullState(pull.ID, "open", "")
589+ s.store.Notify(s.store.Watchers(repo, "pull", pull.ID, pull.Author.ID), *user, repo, "pull", pull.Number, pull.Title, "reopened")
584590 }
585591 case "review":
586592 line, _ := strconv.Atoi(r.FormValue("line"))
@@ -801,6 +807,7 @@ func (s *Server) notifyPull(pull Pull, actor User, verb, body string) {
801807 url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, pull.Repo.Path(), pull.Number)
802808 subject := fmt.Sprintf("[%s] pull request #%d: %s", pull.Repo.Path(), pull.Number, pull.Title)
803809 text := fmt.Sprintf("~%s %s pull request #%d.\n\n%s\n\n%s\n", actor.Name, verb, pull.Number, body, url)
810+ s.store.Notify(s.store.Watchers(pull.Repo, "pull", pull.ID, pull.Author.ID), actor, pull.Repo, "pull", pull.Number, pull.Title, shortVerb(verb))
804811 seen := map[int64]bool{actor.ID: true}
805812 for _, id := range []int64{pull.Author.ID, pull.Repo.Owner.ID} {
806813 if !seen[id] {

static/style.css modified

@@ -382,7 +382,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
382382 .sorts a{color:var(--muted)}
383383 .sorts a.active{color:var(--fg);font-weight:600}
384384 .hit{padding:.9rem 0;border-bottom:1px solid var(--line)}
385-.hit h4{margin:0 0 .25rem;font-size:1.05rem;font-weight:600}
385+.hit h4{margin:0 0 .25rem;font-size:1.05rem;font-weight:400}
386386 .hit h4 a{color:var(--link)}
387387 .hit h4 a.owner{color:var(--muted)}
388388 .hit p{margin:.15rem 0}
@@ -429,7 +429,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
429429 .people ul{list-style:none;margin:0;padding:0}
430430 .people li a{display:grid;grid-template-columns:2.2rem 1fr;column-gap:.6rem;align-items:center;padding:.3rem 0;color:var(--fg);text-decoration:none}
431431 .people li img{grid-row:1/3;display:flex;width:2.2rem;height:2.2rem;border-radius:50%;object-fit:cover;border:1px solid var(--line)}
432-.people li b{font-weight:600;font-size:.93rem;line-height:1.2}
432+.people li b{font-weight:400;font-size:.97rem;line-height:1.2}
433433 .people li small{color:var(--muted);line-height:1.2}
434434 .orghead{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:1.3rem;align-items:start;margin:-.4rem 0 1.3rem;padding-bottom:1.2rem;border-bottom:1px solid var(--bar)}
435435 .orgmark img{display:flex;width:6rem;height:6rem;border-radius:10px;object-fit:cover;border:1px solid var(--line);background:var(--soft)}
@@ -481,3 +481,22 @@ label.radio input{width:auto;margin:0}
481481 .mdbar button:last-of-type{margin-left:auto}
482482 .mdpreview{border:1px solid var(--line);padding:.5rem .7rem;min-height:6rem}
483483 .readme img{max-width:100%}
484+nav.site .bell{position:relative;display:inline-flex;color:var(--muted);padding:.15rem}
485+nav.site .bell:hover,nav.site .bell.on{color:var(--fg);text-decoration:none}
486+nav.site .bell svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round}
487+nav.site .bell .badge{position:absolute;top:-.35rem;left:.85rem;min-width:1rem;padding:0 .25rem;border-radius:.5rem;background:var(--accent);color:#fff;font-size:.65rem;line-height:1rem;text-align:center}
488+.inboxhead{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);margin-bottom:.4rem}
489+.inboxhead h3{border:0;margin:0;padding:.2rem 0 .3rem}
490+.inboxhead form{margin:0}
491+.inbox{list-style:none;margin:0;padding:0}
492+.inbox li a{display:grid;grid-template-columns:1.1rem minmax(0,1fr) auto;gap:.7rem;align-items:center;padding:.5rem .6rem;color:var(--fg);border-bottom:1px solid var(--line);text-decoration:none}
493+.inbox li a:hover{background:var(--soft)}
494+.inbox li.unread a{background:var(--accentsoft)}
495+.inbox li.unread .title{font-weight:600}
496+.inbox .kind{width:16px;height:16px;fill:none;stroke:var(--muted);stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}
497+.inbox li.unread .kind{stroke:var(--link)}
498+.inbox .what{display:flex;flex-direction:column;min-width:0}
499+.inbox .where{font-size:.8rem;color:var(--muted)}
500+.inbox .title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
501+.inbox .who{font-size:.82rem;white-space:nowrap}
502+@media(max-width:700px){.inbox li a{grid-template-columns:1.1rem minmax(0,1fr)}.inbox .who{grid-column:2}}

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} {
280+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema} {
281281 if _, err := db.Exec(ddl); err != nil {
282282 return nil, err
283283 }

templates/layout.html modified

@@ -23,7 +23,7 @@
2323 <a href="/search">explore</a>
2424 {{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}}
2525 <span class="spacer"></span>
26-{{if .User}}<span class="me">{{if .Admin}}<a href="/admin">admin</a>{{end}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a></span><form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="me auth"><a href="/login">Log in</a>{{if ne .Registration "closed"}}<a href="/register">Register</a>{{end}}</span>{{end}}
26+{{if .User}}<span class="me"><a class="bell{{if .Unread}} on{{end}}" href="/notifications" aria-label="Notifications{{if .Unread}}, {{.Unread}} unread{{end}}" title="Notifications"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8a4 4 0 0 0-4 4v2.6L2.6 11h10.8L12 8.4V5.8a4 4 0 0 0-4-4zM6.4 13a1.6 1.6 0 0 0 3.2 0"/></svg>{{if .Unread}}<span class="badge">{{if gt .Unread 99}}99+{{else}}{{.Unread}}{{end}}</span>{{end}}</a>{{if .Admin}}<a href="/admin">admin</a>{{end}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a></span><form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="me auth"><a href="/login">Log in</a>{{if ne .Registration "closed"}}<a href="/register">Register</a>{{end}}</span>{{end}}
2727 </nav>
2828 {{with .Data}}{{with index . "Settings"}}
2929 <nav class="subtabs">

templates/notifications.html added

@@ -0,0 +1,8 @@
1+{{define "content"}}
2+<div class="inboxhead"><h3>Notifications</h3>{{if .Unread}}<form method="post" action="/notifications"><button class="btn small">Mark all as read</button></form>{{end}}</div>
3+{{with .Data.Items}}<ul class="inbox">{{range .}}<li{{if .Unread}} class="unread"{{end}}><a href="/notifications/{{.ID}}">
4+<svg class="kind" viewBox="0 0 16 16" aria-hidden="true">{{if eq .Kind "pull"}}<circle cx="4" cy="3.5" r="1.6"/><circle cx="4" cy="12.5" r="1.6"/><circle cx="12" cy="12.5" r="1.6"/><path d="M4 5.1v5.8M12 10.9V7a2.5 2.5 0 0 0-2.5-2.5H7m1.5-2L7 4.5l1.5 2"/>{{else}}<circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="1.2"/>{{end}}</svg>
5+<span class="what"><span class="where">{{.Repo.Path}} <span class="muted">#{{.Number}}</span></span><span class="title">{{.Title}}</span></span>
6+<span class="who muted">~{{.Actor}} {{.Verb}} this · {{ago .Created}}</span></a></li>{{end}}</ul>
7+{{else}}<p class="info">Nothing here yet. When someone opens, comments on, reviews, merges or closes a pull request or issue you are part of, it shows up here.</p>{{end}}
8+{{end}}

web.go modified

@@ -76,6 +76,7 @@ type page struct {
7676 Logo bool
7777 Icon bool
7878 Theme string
79+ Unread int
7980 }
8081
8182 func newServer(store *Store, root, host, name string, sshUser string) *Server {
@@ -182,7 +183,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p
182183 p.User = s.current(r)
183184 }
184185 if p.User != nil {
185- p.Admin, p.Theme = s.store.Viewer(p.User.ID)
186+ p.Admin, p.Theme, p.Unread = s.store.Viewer(p.User.ID)
186187 }
187188 t, ok := s.pages[name]
188189 if !ok {
@@ -269,6 +270,8 @@ func (s *Server) handler() http.Handler {
269270 mux.HandleFunc("POST /attachments", s.upload)
270271 mux.HandleFunc("GET /attachments/{name}", s.attachment)
271272 mux.HandleFunc("POST /preview", s.preview)
273+ mux.HandleFunc("/notifications", s.inbox)
274+ mux.HandleFunc("GET /notifications/{id}", s.openNotification)
272275 mux.HandleFunc("GET /settings/appearance", s.appearance)
273276 mux.HandleFunc("POST /settings/appearance", s.appearance)
274277 mux.HandleFunc("GET /settings/tokens", s.tokensPage)