From b041b32aa2ac4d1bd82bbff506745988847ff500 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 8 Sep 2026 12:12:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(beskeder):=20appens=20ikon=20b=C3=A6rer=20?= =?UTF-8?q?tallet=20p=C3=A5=20ul=C3=A6ste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Klokken i hovedet siger, hvad der er nyt — 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: mærket i Dock'en på en Mac, på proceslinjen på en pc. Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og kender tallet, og service-workeren, når den ikke er — og det er netop dér, mærket er det, der siger, at der er sket noget. Tallet kommer med i push-beskeden frem for at blive talt op af service-workeren. Den husker ingenting mellem to opvågninger og kunne derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en tælling, der fejler, udelader feltet frem for at sende et nul — så står mærket, som det stod, i stedet for at blive tørret af en optælling, der ikke lykkedes. Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud — det står i Dock'en, også når ingen er logget ind. Co-Authored-By: Claude Opus 5 --- internal/httpapi/notifications_handlers.go | 11 +++++- internal/push/push.go | 7 ++++ internal/push/push_test.go | 23 +++++++++++ web/src/lib/badge.js | 46 ++++++++++++++++++++++ web/src/lib/components/Sidebar.svelte | 4 ++ web/src/routes/+layout.svelte | 8 ++++ web/src/service-worker.js | 13 ++++++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/badge.js 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 @@