feat(inbox): notification panel under the bell
6 files changed, +59 −27
- flow_test.go modified +5 −5
- inbox.go modified +16 −18
- static/editor.js modified +6 −0
- static/style.css modified +29 −3
- templates/layout.html modified +1 −1
- web.go modified +2 −0
flow_test.go modified
| @@ -459,22 +459,22 @@ func TestNotifications(t *testing.T) { | ||
| 459 | 459 | } |
| 460 | 460 | s.store.AddIssueComment(issue.ID, carol, "same here") |
| 461 | 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 { | |
| 462 | + if n := s.store.Notifications(alice.ID, 100); len(n) != 1 || n[0].Actor != "carol" || !n[0].Unread { | |
| 463 | 463 | t.Errorf("thread not collapsed into one entry: %+v", n) |
| 464 | 464 | } |
| 465 | 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" { | |
| 466 | + if n := s.store.Notifications(carol.ID, 100); len(n) != 1 || n[0].Verb != "closed" { | |
| 467 | 467 | t.Errorf("participant not notified: %+v", n) |
| 468 | 468 | } |
| 469 | 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 { | |
| 470 | + if n := s.store.Notifications(bob.ID, 100); len(n) != 1 || n[0].Unread { | |
| 471 | 471 | t.Errorf("viewing the issue left it unread: %+v", n) |
| 472 | 472 | } |
| 473 | - if w := post(h, "/notifications", url.Values{}, cc); w.Code != http.StatusSeeOther || s.store.Notifications(carol.ID)[0].Unread { | |
| 473 | + if w := post(h, "/notifications", url.Values{}, cc); w.Code != http.StatusSeeOther || s.store.Notifications(carol.ID, 100)[0].Unread { | |
| 474 | 474 | t.Error("mark all as read did nothing") |
| 475 | 475 | } |
| 476 | 476 | s.store.UpdateRepo(repo.ID, "", Private, "") |
| 477 | - if n := s.store.Notifications(carol.ID); len(n) != 0 { | |
| 477 | + if n := s.store.Notifications(carol.ID, 100); len(n) != 0 { | |
| 478 | 478 | t.Errorf("private repository leaked through notifications: %+v", n) |
| 479 | 479 | } |
| 480 | 480 | } |
inbox.go modified
| @@ -84,32 +84,29 @@ func (s *Store) Notify(users []int64, actor User, repo Repo, kind string, number | ||
| 84 | 84 | } |
| 85 | 85 | } |
| 86 | 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) | |
| 87 | +func (s *Store) Notifications(user int64, limit int) []Notification { | |
| 88 | + rows, err := s.db.Query(`select n.id, n.kind, n.number, n.title, a.name, n.verb, n.created, n.unread, r.id, r.name, r.private, o.id, o.name | |
| 89 | + from notifications n join users a on a.id = n.actor join repos r on r.id = n.repo join users o on o.id = r.owner | |
| 90 | + where n.user = ? order by n.unread desc, n.created desc limit ?`, user, limit) | |
| 90 | 91 | if err != nil { |
| 91 | 92 | return nil |
| 92 | 93 | } |
| 93 | - var out []Notification | |
| 94 | - var repos []int64 | |
| 94 | + var all []Notification | |
| 95 | 95 | for rows.Next() { |
| 96 | 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 { | |
| 97 | + var created, private int64 | |
| 98 | + if rows.Scan(&n.ID, &n.Kind, &n.Number, &n.Title, &n.Actor, &n.Verb, &created, &n.Unread, &n.Repo.ID, &n.Repo.Name, &private, &n.Repo.Owner.ID, &n.Repo.Owner.Name) == nil { | |
| 99 | 99 | n.Created = time.Unix(created, 0) |
| 100 | - out = append(out, n) | |
| 101 | - repos = append(repos, n.Repo.ID) | |
| 100 | + n.Repo.Private = private == 1 | |
| 101 | + all = append(all, n) | |
| 102 | 102 | } |
| 103 | 103 | } |
| 104 | 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 | |
| 105 | + shown := all[:0] | |
| 106 | + for _, n := range all { | |
| 107 | + if !n.Repo.Private || s.CanRead(n.Repo, &User{ID: user}) { | |
| 108 | + shown = append(shown, n) | |
| 110 | 109 | } |
| 111 | - n.Repo = repo | |
| 112 | - shown = append(shown, n) | |
| 113 | 110 | } |
| 114 | 111 | return shown |
| 115 | 112 | } |
| @@ -128,7 +125,8 @@ func (s *Server) inbox(w http.ResponseWriter, r *http.Request) { | ||
| 128 | 125 | http.Redirect(w, r, "/notifications", http.StatusSeeOther) |
| 129 | 126 | return |
| 130 | 127 | } |
| 131 | - s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID)}}) | |
| 128 | + s.store.db.Exec(`delete from notifications where user = ? and unread = 0 and created < ?`, u.ID, time.Now().AddDate(0, 0, -90).Unix()) | |
| 129 | + s.render(w, r, "notifications", page{Title: "Notifications", User: u, Data: map[string]any{"Items": s.store.Notifications(u.ID, 100)}}) | |
| 132 | 130 | } |
| 133 | 131 | |
| 134 | 132 | func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) { |
| @@ -137,7 +135,7 @@ func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) { | ||
| 137 | 135 | return |
| 138 | 136 | } |
| 139 | 137 | id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 140 | - for _, n := range s.store.Notifications(u.ID) { | |
| 138 | + for _, n := range s.store.Notifications(u.ID, 100) { | |
| 141 | 139 | if n.ID == id { |
| 142 | 140 | s.store.ReadThread(u.ID, n.Repo.ID, n.Kind, n.Number) |
| 143 | 141 | http.Redirect(w, r, n.Link(), http.StatusSeeOther) |
static/editor.js modified
| @@ -68,3 +68,9 @@ document.querySelectorAll("textarea[data-md]").forEach(function (area) { | ||
| 68 | 68 | }); |
| 69 | 69 | } |
| 70 | 70 | }); |
| 71 | + | |
| 72 | +document.addEventListener("click", function (e) { | |
| 73 | + document.querySelectorAll("details.bell[open]").forEach(function (d) { | |
| 74 | + if (!d.contains(e.target)) d.open = false; | |
| 75 | + }); | |
| 76 | +}); | |
static/style.css modified
| @@ -481,10 +481,36 @@ 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} | |
| 484 | +nav.site .bell{position:relative} | |
| 485 | +nav.site .bell summary{list-style:none;cursor:pointer;position:relative;display:inline-flex;color:var(--muted);padding:.15rem} | |
| 486 | +nav.site .bell summary::-webkit-details-marker{display:none} | |
| 487 | +nav.site .bell summary:hover,nav.site .bell.on summary,nav.site .bell[open] summary{color:var(--fg)} | |
| 488 | +nav.site .bell summary svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round} | |
| 487 | 489 | 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} |
| 490 | +.inboxpanel{position:absolute;z-index:20;top:calc(100% + 8px);right:-.5rem;width:26rem;max-width:calc(100vw - 2rem);background:var(--bg);border:1px solid var(--line);box-shadow:0 6px 18px rgba(0,0,0,.1);display:flex;flex-direction:column;font-size:.9rem} | |
| 491 | +.inboxpanel>input{display:none} | |
| 492 | +.inboxpanel .seg{display:flex;justify-content:space-between;gap:.5rem;padding:.5rem;border-bottom:1px solid var(--line)} | |
| 493 | +.inboxpanel .seg span{display:flex;background:var(--raised);padding:2px} | |
| 494 | +.inboxpanel .seg label{padding:.1rem .6rem;color:var(--muted);cursor:pointer;font-size:.85rem} | |
| 495 | +.inboxpanel .seg label:hover{color:var(--fg)} | |
| 496 | +#nall:checked~.seg label[for=nall],#nunread:checked~.seg label[for=nunread],#kall:checked~.seg label[for=kall],#kpull:checked~.seg label[for=kpull],#kissue:checked~.seg label[for=kissue]{background:var(--bg);color:var(--fg)} | |
| 497 | +#nunread:checked~ul li:not(.unread),#kpull:checked~ul li:not(.pull),#kissue:checked~ul li:not(.issue){display:none} | |
| 498 | +.inboxpanel ul{list-style:none;margin:0;padding:0;max-height:22rem;overflow:auto} | |
| 499 | +.inboxpanel li a{display:grid;grid-template-columns:1rem minmax(0,1fr);gap:.6rem;padding:.5rem .7rem;color:var(--fg);border-bottom:1px solid var(--line);text-decoration:none} | |
| 500 | +.inboxpanel li a:hover{background:var(--soft)} | |
| 501 | +.inboxpanel li.unread a{background:var(--accentsoft)} | |
| 502 | +.inboxpanel li a>span{display:flex;flex-direction:column;min-width:0} | |
| 503 | +.inboxpanel .where,.inboxpanel .who{font-size:.78rem;color:var(--muted)} | |
| 504 | +.inboxpanel .title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| 505 | +.inboxpanel li.unread .title{font-weight:600} | |
| 506 | +.inboxpanel .kind{width:15px;height:15px;margin-top:.15rem;fill:none;stroke:var(--muted);stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round} | |
| 507 | +.inboxpanel li.unread .kind{stroke:var(--link)} | |
| 508 | +.inboxpanel .empty{display:none;flex-direction:column;align-items:center;padding:2.2rem 1rem 1.8rem;color:var(--muted)} | |
| 509 | +.inboxpanel .empty .tray{width:46px;height:46px;fill:none;stroke:var(--edge);stroke-width:1.4;stroke-linejoin:round} | |
| 510 | +.inboxpanel .empty p{margin:.6rem 0 0} | |
| 511 | +.inboxpanel:not(:has(li)) .empty,#nall:checked~#kpull:checked~ul:not(:has(li.pull))~.empty,#nall:checked~#kissue:checked~ul:not(:has(li.issue))~.empty,#nunread:checked~#kall:checked~ul:not(:has(li.unread))~.empty,#nunread:checked~#kpull:checked~ul:not(:has(li.unread.pull))~.empty,#nunread:checked~#kissue:checked~ul:not(:has(li.unread.issue))~.empty{display:flex} | |
| 512 | +.inboxpanel .viewall{align-self:flex-end;padding:.45rem .7rem;font-size:.82rem;color:var(--muted)} | |
| 513 | +.inboxpanel .viewall:hover{color:var(--fg)} | |
| 488 | 514 | .inboxhead{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);margin-bottom:.4rem} |
| 489 | 515 | .inboxhead h3{border:0;margin:0;padding:.2rem 0 .3rem} |
| 490 | 516 | .inboxhead form{margin:0} |
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"><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}} | |
| 26 | +{{if .User}}<span class="me"><details class="bell{{if .Unread}} on{{end}}"><summary 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}}</summary><div class="inboxpanel"><input type="radio" name="nview" id="nall" checked><input type="radio" name="nview" id="nunread"{{if .Unread}} checked{{end}}><input type="radio" name="nkind" id="kall" checked><input type="radio" name="nkind" id="kpull"><input type="radio" name="nkind" id="kissue"><div class="seg"><span><label for="nall">Inbox</label><label for="nunread">Unread</label></span><span><label for="kall">All</label><label for="kpull">Pulls</label><label for="kissue">Issues</label></span></div><ul>{{range .Inbox}}<li class="{{.Kind}}{{if .Unread}} unread{{end}}"><a href="/notifications/{{.ID}}"><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><span><span class="where">{{.Repo.Path}} #{{.Number}}</span><span class="title">{{.Title}}</span><span class="who">~{{.Actor}} {{.Verb}} this · {{ago .Created}}</span></span></a></li>{{end}}</ul><div class="empty"><svg class="tray" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13l2.5-7.5A2 2 0 0 1 7.4 4h9.2a2 2 0 0 1 1.9 1.5L21 13v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 13h5l1.5 2.5h5L16 13h5"/></svg><p>All caught up</p></div><a class="viewall" href="/notifications">View all ▸</a></div></details>{{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"> |
web.go modified
| @@ -77,6 +77,7 @@ type page struct { | ||
| 77 | 77 | Icon bool |
| 78 | 78 | Theme string |
| 79 | 79 | Unread int |
| 80 | + Inbox []Notification | |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | 83 | func newServer(store *Store, root, host, name string, sshUser string) *Server { |
| @@ -184,6 +185,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p | ||
| 184 | 185 | } |
| 185 | 186 | if p.User != nil { |
| 186 | 187 | p.Admin, p.Theme, p.Unread = s.store.Viewer(p.User.ID) |
| 188 | + p.Inbox = s.store.Notifications(p.User.ID, 12) | |
| 187 | 189 | } |
| 188 | 190 | t, ok := s.pages[name] |
| 189 | 191 | if !ok { |