feat(inbox): notifications
11 files changed, +303 −18
- admin.go modified +2 −2
- avatar.go modified +52 −11
- flow_test.go modified +45 −0
- inbox.go added +155 −0
- issues.go modified +7 −0
- pull.go modified +7 −0
- static/style.css modified +21 −2
- store.go modified +1 −1
- templates/layout.html modified +1 −1
- templates/notifications.html added +8 −0
- web.go modified +4 −1
admin.go modified
| @@ -79,8 +79,8 @@ func (s *Store) IsAdmin(id int64) bool { | ||
| 79 | 79 | |
| 80 | 80 | var themes = []string{"", "light", "dark"} |
| 81 | 81 | |
| 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) | |
| 84 | 84 | return |
| 85 | 85 | } |
| 86 | 86 | |
avatar.go modified
| @@ -52,28 +52,69 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) { | ||
| 52 | 52 | return out.Bytes(), nil |
| 53 | 53 | } |
| 54 | 54 | |
| 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 | + | |
| 55 | 70 | func identicon(seed string) []byte { |
| 56 | 71 | sum := sha256.Sum256([]byte(seed)) |
| 57 | 72 | 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 | |
| 60 | 75 | 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 | + } | |
| 70 | 93 | } |
| 71 | 94 | } |
| 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) | |
| 72 | 102 | var out bytes.Buffer |
| 73 | 103 | png.Encode(&out, img) |
| 74 | 104 | return out.Bytes() |
| 75 | 105 | } |
| 76 | 106 | |
| 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 | + | |
| 77 | 118 | func hsl(h, s, l float64) color.RGBA { |
| 78 | 119 | q := l + s - l*s |
| 79 | 120 | if l < 0.5 { |
flow_test.go modified
| @@ -3,6 +3,7 @@ package main | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "encoding/json" |
| 6 | + "fmt" | |
| 6 | 7 | "image" |
| 7 | 8 | "image/png" |
| 8 | 9 | "net/http" |
| @@ -433,3 +434,47 @@ func TestAttachments(t *testing.T) { | ||
| 433 | 434 | t.Errorf("preview gave %q", body) |
| 434 | 435 | } |
| 435 | 436 | } |
| 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) | ||
| 192 | 192 | 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")}}) |
| 193 | 193 | return |
| 194 | 194 | } |
| 195 | + s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "opened") | |
| 195 | 196 | if repo.Owner.ID != user.ID { |
| 196 | 197 | s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] issue #%d: %s", repo.Path(), issue.Number, issue.Title), |
| 197 | 198 | 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 | ||
| 215 | 216 | } |
| 216 | 217 | user := rp.User |
| 217 | 218 | 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 | + } | |
| 218 | 222 | back := fmt.Sprintf("/%s/issues/%d", repo.Path(), issue.Number) |
| 219 | 223 | if r.Method == "POST" { |
| 220 | 224 | if user == nil { |
| @@ -233,6 +237,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s | ||
| 233 | 237 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 234 | 238 | return |
| 235 | 239 | } |
| 240 | + s.store.Notify(s.store.Watchers(repo, "issue", issue.ID, issue.Author.ID), *user, repo, "issue", issue.Number, issue.Title, "commented on") | |
| 236 | 241 | for _, id := range []int64{issue.Author.ID, repo.Owner.ID} { |
| 237 | 242 | if id != user.ID { |
| 238 | 243 | 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 | ||
| 244 | 249 | case "close": |
| 245 | 250 | if canManage { |
| 246 | 251 | 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") | |
| 247 | 253 | } |
| 248 | 254 | case "reopen": |
| 249 | 255 | if canManage { |
| 250 | 256 | 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") | |
| 251 | 258 | } |
| 252 | 259 | default: |
| 253 | 260 | 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) { | ||
| 528 | 528 | } |
| 529 | 529 | g.RecordRevision(pull) |
| 530 | 530 | 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") | |
| 531 | 532 | if repo.Owner.ID != user.ID { |
| 532 | 533 | s.notify(s.store.VerifiedEmail(repo.Owner.ID), fmt.Sprintf("[%s] pull request #%d: %s", repo.Path(), pull.Number, pull.Title), |
| 533 | 534 | 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 | ||
| 561 | 562 | } |
| 562 | 563 | isOwner := s.store.CanWrite(repo, user) |
| 563 | 564 | 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 | + } | |
| 564 | 568 | if r.Method == "POST" { |
| 565 | 569 | if user == nil { |
| 566 | 570 | 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 | ||
| 577 | 581 | case "close": |
| 578 | 582 | if (isOwner || isAuthor) && pull.State == "open" { |
| 579 | 583 | 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") | |
| 580 | 585 | } |
| 581 | 586 | case "reopen": |
| 582 | 587 | if (isOwner || isAuthor) && pull.State == "closed" { |
| 583 | 588 | 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") | |
| 584 | 590 | } |
| 585 | 591 | case "review": |
| 586 | 592 | line, _ := strconv.Atoi(r.FormValue("line")) |
| @@ -801,6 +807,7 @@ func (s *Server) notifyPull(pull Pull, actor User, verb, body string) { | ||
| 801 | 807 | url := fmt.Sprintf("https://%s/%s/pulls/%d", s.host, pull.Repo.Path(), pull.Number) |
| 802 | 808 | subject := fmt.Sprintf("[%s] pull request #%d: %s", pull.Repo.Path(), pull.Number, pull.Title) |
| 803 | 809 | 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)) | |
| 804 | 811 | seen := map[int64]bool{actor.ID: true} |
| 805 | 812 | for _, id := range []int64{pull.Author.ID, pull.Repo.Owner.ID} { |
| 806 | 813 | if !seen[id] { |
static/style.css modified
| @@ -382,7 +382,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 382 | 382 | .sorts a{color:var(--muted)} |
| 383 | 383 | .sorts a.active{color:var(--fg);font-weight:600} |
| 384 | 384 | .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} | |
| 386 | 386 | .hit h4 a{color:var(--link)} |
| 387 | 387 | .hit h4 a.owner{color:var(--muted)} |
| 388 | 388 | .hit p{margin:.15rem 0} |
| @@ -429,7 +429,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 429 | 429 | .people ul{list-style:none;margin:0;padding:0} |
| 430 | 430 | .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} |
| 431 | 431 | .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} | |
| 433 | 433 | .people li small{color:var(--muted);line-height:1.2} |
| 434 | 434 | .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)} |
| 435 | 435 | .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} | ||
| 481 | 481 | .mdbar button:last-of-type{margin-left:auto} |
| 482 | 482 | .mdpreview{border:1px solid var(--line);padding:.5rem .7rem;min-height:6rem} |
| 483 | 483 | .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) { | ||
| 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} { | |
| 280 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema} { | |
| 281 | 281 | if _, err := db.Exec(ddl); err != nil { |
| 282 | 282 | return nil, err |
| 283 | 283 | } |
templates/layout.html modified
| @@ -23,7 +23,7 @@ | ||
| 23 | 23 | <a href="/search">explore</a> |
| 24 | 24 | {{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}} |
| 25 | 25 | <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}} | |
| 27 | 27 | </nav> |
| 28 | 28 | {{with .Data}}{{with index . "Settings"}} |
| 29 | 29 | <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 { | ||
| 76 | 76 | Logo bool |
| 77 | 77 | Icon bool |
| 78 | 78 | Theme string |
| 79 | + Unread int | |
| 79 | 80 | } |
| 80 | 81 | |
| 81 | 82 | 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 | ||
| 182 | 183 | p.User = s.current(r) |
| 183 | 184 | } |
| 184 | 185 | 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) | |
| 186 | 187 | } |
| 187 | 188 | t, ok := s.pages[name] |
| 188 | 189 | if !ok { |
| @@ -269,6 +270,8 @@ func (s *Server) handler() http.Handler { | ||
| 269 | 270 | mux.HandleFunc("POST /attachments", s.upload) |
| 270 | 271 | mux.HandleFunc("GET /attachments/{name}", s.attachment) |
| 271 | 272 | mux.HandleFunc("POST /preview", s.preview) |
| 273 | + mux.HandleFunc("/notifications", s.inbox) | |
| 274 | + mux.HandleFunc("GET /notifications/{id}", s.openNotification) | |
| 272 | 275 | mux.HandleFunc("GET /settings/appearance", s.appearance) |
| 273 | 276 | mux.HandleFunc("POST /settings/appearance", s.appearance) |
| 274 | 277 | mux.HandleFunc("GET /settings/tokens", s.tokensPage) |