~gitpocket/gitpocket

1

feat(inbox): notification panel under the bell

commit
3e6b98c68fd45f770106cc8797ef6ee93e9e0566 browse tree
parent
ef9167bb0e271e16e00190335578bd4a6a870e89
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-07 16:19 UTC

6 files changed, +59 −27

flow_test.go modified

@@ -459,22 +459,22 @@ func TestNotifications(t *testing.T) {
459459 }
460460 s.store.AddIssueComment(issue.ID, carol, "same here")
461461 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 {
463463 t.Errorf("thread not collapsed into one entry: %+v", n)
464464 }
465465 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" {
467467 t.Errorf("participant not notified: %+v", n)
468468 }
469469 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 {
471471 t.Errorf("viewing the issue left it unread: %+v", n)
472472 }
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 {
474474 t.Error("mark all as read did nothing")
475475 }
476476 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 {
478478 t.Errorf("private repository leaked through notifications: %+v", n)
479479 }
480480 }

inbox.go modified

@@ -84,32 +84,29 @@ func (s *Store) Notify(users []int64, actor User, repo Repo, kind string, number
8484 }
8585 }
8686
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)
9091 if err != nil {
9192 return nil
9293 }
93- var out []Notification
94- var repos []int64
94+ var all []Notification
9595 for rows.Next() {
9696 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 {
9999 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)
102102 }
103103 }
104104 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)
110109 }
111- n.Repo = repo
112- shown = append(shown, n)
113110 }
114111 return shown
115112 }
@@ -128,7 +125,8 @@ func (s *Server) inbox(w http.ResponseWriter, r *http.Request) {
128125 http.Redirect(w, r, "/notifications", http.StatusSeeOther)
129126 return
130127 }
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)}})
132130 }
133131
134132 func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) {
@@ -137,7 +135,7 @@ func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) {
137135 return
138136 }
139137 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) {
141139 if n.ID == id {
142140 s.store.ReadThread(u.ID, n.Repo.ID, n.Kind, n.Number)
143141 http.Redirect(w, r, n.Link(), http.StatusSeeOther)

static/editor.js modified

@@ -68,3 +68,9 @@ document.querySelectorAll("textarea[data-md]").forEach(function (area) {
6868 });
6969 }
7070 });
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}
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}
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}
487489 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)}
488514 .inboxhead{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);margin-bottom:.4rem}
489515 .inboxhead h3{border:0;margin:0;padding:.2rem 0 .3rem}
490516 .inboxhead form{margin:0}

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"><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}}
2727 </nav>
2828 {{with .Data}}{{with index . "Settings"}}
2929 <nav class="subtabs">

web.go modified

@@ -77,6 +77,7 @@ type page struct {
7777 Icon bool
7878 Theme string
7979 Unread int
80+ Inbox []Notification
8081 }
8182
8283 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
184185 }
185186 if p.User != nil {
186187 p.Admin, p.Theme, p.Unread = s.store.Viewer(p.User.ID)
188+ p.Inbox = s.store.Notifications(p.User.ID, 12)
187189 }
188190 t, ok := s.pages[name]
189191 if !ok {