diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go index 8bf7236..f56a0c2 100644 --- a/internal/httpapi/notifications_handlers.go +++ b/internal/httpapi/notifications_handlers.go @@ -214,7 +214,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome { if projectID == "" { target = "/" } - payload := push.Payload{Title: title, Body: body, URL: target} + // Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren, + // som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge + // én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig + // ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er + // stadig en besked. + unread, err := s.db.UnreadNotificationCount(ctx, userID) + if err != nil { + s.log.Warn("unread count for push", "err", err) + } + payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread} subject := s.vapidSubject() for _, sub := range subs { err := push.Send(ctx, push.Subscription{ diff --git a/internal/push/push.go b/internal/push/push.go index ccd0ea8..a5c152a 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -56,6 +56,13 @@ type Payload struct { Body string `json:"body,omitempty"` URL string `json:"url,omitempty"` Tag string `json:"tag,omitempty"` + // Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander. + // + // Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem + // to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og + // et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet + // er serverens, og serveren ved det i forvejen. + Unread int `json:"unread,omitempty"` } var b64 = base64.RawURLEncoding diff --git a/internal/push/push_test.go b/internal/push/push_test.go index 614e0fc..b2c0676 100644 --- a/internal/push/push_test.go +++ b/internal/push/push_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ecdh" "crypto/rand" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID { } return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"} } + +// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der +// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et +// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der +// ikke lykkedes. +func TestPayloadCarriesTheUnreadCount(t *testing.T) { + with, err := json.Marshal(Payload{Title: "hej", Unread: 3}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(with), `"unread":3`) { + t.Errorf("payload = %s, want the unread count in it", with) + } + + without, err := json.Marshal(Payload{Title: "hej"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(without), "unread") { + t.Errorf("payload = %s, want no count when there is none to send", without) + } +} diff --git a/web/src/lib/badge.js b/web/src/lib/badge.js new file mode 100644 index 0000000..07cbaf0 --- /dev/null +++ b/web/src/lib/badge.js @@ -0,0 +1,46 @@ +/** + * Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc. + * + * Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det + * halve af pointen med at få besked er at få den, mens man laver noget andet, og + * det eneste sted, appen kan sige noget dér, er på sit eget ikon. + * + * Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved + * hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`- + * lytteren i service-worker.js, som får tallet med i beskeden. + * + * Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der + * lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst. + */ + +/** + * Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en + * eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør + * ingenting. Firefox kan ikke endnu. + */ +export function supported() { + return typeof navigator !== 'undefined' && 'setAppBadge' in navigator; +} + +/** + * Sætter mærket til `n`, eller fjerner det ved nul. + * + * Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken, + * der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren + * siger nej. + */ +export function setBadge(n) { + if (!supported()) return; + const count = Number(n) || 0; + try { + const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge(); + done?.catch?.(() => {}); + } catch { + // Nogle browsere kaster i stedet for at afvise. Det er det samme svar. + } +} + +/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */ +export function clearBadge() { + setBadge(0); +} diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 8a831ad..4f0167c 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -1,6 +1,7 @@