From ab77024ec970c338795c3a43179badaf94903680 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 8 Sep 2026 11:40:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(beskeder):=20klokken=20siger=20til,=20n?= =?UTF-8?q?=C3=A5r=20nogen=20r=C3=B8rer=20noget,=20der=20er=20dit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En delt note blev rettet af en anden, og man opdagede det kun ved at åbne den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved det, man deler, skal man kunne se uden at lede efter det. Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke på serveren, så et engelsk interface får en engelsk klokke. Beskeder om den samme note bliver slået sammen, mens de er ulæste. En note gemmes på en tastepause, så en række pr. gem ville lave én person, der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er den næste rettelse en ny ting at få at vide — ikke en gentagelse. note_id får sin egen søjle ved siden af project_id og task_id frem for et generisk (kind, id)-par: de tre er ikke det samme slags nul, og fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et generisk par ikke have. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 12 +- internal/httpapi/notes_handlers.go | 6 + internal/httpapi/notifications_handlers.go | 70 ++++- internal/httpapi/notifications_test.go | 196 +++++++++++++ internal/httpapi/tasks_handlers.go | 18 ++ .../0033_notifications_about_notes.sql | 18 ++ internal/store/note_shares.go | 38 +++ internal/store/notifications.go | 40 ++- web/src/lib/components/Notifications.svelte | 268 ++++++++++++++++++ web/src/lib/locales/da.js | 12 + web/src/lib/locales/en.js | 13 + web/src/lib/stores.svelte.js | 55 ++++ web/src/routes/+layout.svelte | 3 + 13 files changed, 737 insertions(+), 12 deletions(-) create mode 100644 internal/httpapi/notifications_test.go create mode 100644 internal/store/migrations/0033_notifications_about_notes.sql create mode 100644 web/src/lib/components/Notifications.svelte diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1f1d92e..d09d277 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4560,12 +4560,22 @@ components: Notification: type: object + description: > + Something somebody else did that you need to know about. `kind` says what + happened and the client composes the sentence from it, so the bell follows + the reader's language; `title` is the server's own Danish wording, used for + Web Push, which has no dictionary to look words up in. properties: id: { type: string } actor_name: { type: string } project_id: { type: string } task_id: { type: string } - kind: { type: string } + note_id: + description: Set on a notification about a note — a shared note somebody else edited. + type: string + kind: + type: string + enum: [assigned, note.changed, comment] title: { type: string } body: { type: string } read: { type: boolean } diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 47b2045..5ede579 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -306,6 +306,12 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) { s.followLinks(r, n) } + // Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller + // lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om + // det ville lære folk at overse dem alle sammen. + if before != n.Body { + s.notifyNoteChanged(r, n) + } writeJSON(w, http.StatusOK, n) } diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go index 8468a5e..8bf7236 100644 --- a/internal/httpapi/notifications_handlers.go +++ b/internal/httpapi/notifications_handlers.go @@ -19,6 +19,7 @@ type notificationJSON struct { ActorName string `json:"actor_name,omitempty"` ProjectID string `json:"project_id,omitempty"` TaskID string `json:"task_id,omitempty"` + NoteID string `json:"note_id,omitempty"` Kind string `json:"kind"` Title string `json:"title"` Body string `json:"body,omitempty"` @@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) for _, n := range list { out = append(out, notificationJSON{ ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID, - Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil, - CreatedAt: n.CreatedAt.Format(time.RFC3339), + NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body, + Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339), }) } writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread}) @@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) { } s.hub.PublishToUser(n.UserID, "notification", notificationJSON{ - ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind, - Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339), + ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID, + NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body, + CreatedAt: n.CreatedAt.Format(time.RFC3339), }) // Detached from the request: a push round trip to Google or Mozilla has no @@ -102,6 +104,66 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind } } +// notifyAssigned tells somebody a task is now theirs. +// +// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti +// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt +// hvad de lige har gjort. +func (s *Server) notifyAssigned(r *http.Request, t *store.Task) { + if t == nil || t.AssigneeID == "" { + return + } + actor := userFrom(r.Context()) + s.notify(r, &store.Notification{ + UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID, + Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content, + }) +} + +// notifyNoteChanged tells everybody who can see a note that somebody else wrote in +// it — folded into one line per note while it is unread. +// +// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er +// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt, +// og den ene skal have en ny linje, mens den anden får sin gamle rykket op. +func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) { + if n == nil { + return + } + actor := userFrom(r.Context()) + audience, err := s.db.NoteAudience(r.Context(), n.ID) + if err != nil { + s.log.Warn("note audience for notification", "err", err, "note", n.ID) + return + } + title := actor.Name + " rettede en note" + body := n.Title + for _, userID := range audience { + if userID == actor.ID { + continue + } + // Fold first, and only write a row when there was nothing to fold into. + // Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue, + // hvor klokken siger to. + folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body) + if err != nil { + s.log.Warn("fold note notification", "err", err, "note", n.ID) + continue + } + if folded { + // Klokken skal stadig vide det: tallet er uændret, men linjen er rykket + // op og siger nu et andet navn. Uden det her opdager en åben fane først + // den sammenlagte rettelse ved næste genindlæsning. + s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID}) + continue + } + s.notify(r, &store.Notification{ + UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID, + Kind: "note.changed", Title: title, Body: body, + }) + } +} + // PushToUser is pushToUser for callers outside this package — the reminder job, // which has the schedule but not the keys. func (s *Server) PushToUser(userID, title, body, projectID string) { diff --git a/internal/httpapi/notifications_test.go b/internal/httpapi/notifications_test.go new file mode 100644 index 0000000..774b9c5 --- /dev/null +++ b/internal/httpapi/notifications_test.go @@ -0,0 +1,196 @@ +package httpapi + +import ( + "net/http" + "testing" +) + +// notifications reads somebody's bell. +func notifications(t *testing.T, ts *testServer) ([]any, float64) { + t.Helper() + resp, out := ts.do(t, "GET", "/api/v1/notifications", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list notifications: status %d", resp.StatusCode) + } + list, _ := out["notifications"].([]any) + unread, _ := out["unread"].(float64) + return list, unread +} + +func firstOf(t *testing.T, list []any) map[string]any { + t.Helper() + if len(list) == 0 { + t.Fatal("ingen beskeder") + } + n, _ := list[0].(map[string]any) + return n +} + +// At blive tildelt en opgave giver besked — og kun ved selve skiftet. +func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + other := owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + + _, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"}) + projectID, _ := project["id"].(string) + // Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver + // medlem med det samme. + if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", + map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 { + t.Fatalf("invitér: %d", resp.StatusCode) + } + + resp, task := owner.do(t, "POST", "/api/v1/tasks", + map[string]any{"project_id": projectID, "content": "ring til revisor"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("opret opgave: %d", resp.StatusCode) + } + taskID, _ := task["id"].(string) + + // Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul — + // invitationen kan selv have sagt noget — og prøven handler om forskellen. + before, _ := notifications(t, other) + + if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID, + map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK { + t.Fatalf("tildel: %d", resp.StatusCode) + } + + list, _ := notifications(t, other) + if len(list) != len(before)+1 { + t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1) + } + n := firstOf(t, list) + if n["kind"] != "assigned" { + t.Errorf("kind = %v, want assigned", n["kind"]) + } + if n["task_id"] != taskID { + t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"]) + } + if n["actor_name"] != "Kristian" { + t.Errorf("actor_name = %v", n["actor_name"]) + } + + // En rettelse af noget andet, mens den samme tildeling står i forespørgslen, + // er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver + // ændring, gør — og så bliver klokken ubrugelig på en dag. + if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID, + map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK { + t.Fatalf("ret: %d", resp.StatusCode) + } + if now, _ := notifications(t, other); len(now) != len(before)+1 { + t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now)) + } + + // Og den, der tildeler sig selv, får ingenting. + me := userID(t, owner, "kristian@example.dk") + if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID, + map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK { + t.Fatalf("tildel til sig selv: %d", resp.StatusCode) + } + if list, _ := notifications(t, owner); len(list) != 0 { + t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list)) + } +} + +// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én, +// så længe den ikke er læst. +func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + other := owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + + noteID := owner.createNote(t, "# Aftale om levering\nfirst") + if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares", + map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK { + t.Fatal("del noten") + } + + // Ejeren skriver. Sofie skal høre det. + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten") + } + list, unread := notifications(t, other) + if unread != 1 || len(list) != 1 { + t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread) + } + n := firstOf(t, list) + if n["kind"] != "note.changed" || n["note_id"] != noteID { + t.Errorf("besked = %v", n) + } + + // En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note + // gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve + // ens linjer i klokken. + for i := 0; i < 3; i++ { + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten igen") + } + } + if list, unread := notifications(t, other); len(list) != 1 || unread != 1 { + t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list)) + } + + // Læst, og så en rettelse mere: dét er en ny ting at få at vide. + if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent { + t.Fatal("markér som læst") + } + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten efter læsningen") + } + if list, unread := notifications(t, other); len(list) != 2 || unread != 1 { + t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 2 og 1", len(list), unread) + } + + // Ejeren har ikke fået besked om sine egne rettelser undervejs. + if list, _ := notifications(t, owner); len(list) != 0 { + t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list)) + } + + // Og den anden vej: Sofie skriver, ejeren hører det. + if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK { + t.Fatal("Sofie retter") + } + list, _ = notifications(t, owner) + if len(list) != 1 { + t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list)) + } + if firstOf(t, list)["actor_name"] != "Sofie" { + t.Errorf("beskeden nævnte ikke hvem: %v", list[0]) + } + + // En ændring, der ikke er tekst, er ikke en rettelse at give besked om. + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK { + t.Fatal("stjernemarkér") + } + if list, _ := notifications(t, other); len(list) != 2 { + t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list)) + } +} + +// En note, ingen deler, er ingen at give besked. +func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + other := owner.newUser(t, "sofie@example.dk", "Sofie") + + noteID := owner.createNote(t, "# Min egen\nintet") + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten") + } + if list, _ := notifications(t, other); len(list) != 0 { + t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list)) + } + if list, _ := notifications(t, owner); len(list) != 0 { + t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list)) + } +} diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index 815aa4f..872218b 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { t.Labels = req.Labels s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content}) s.publish(t.ProjectID, "task.created", toTaskJSON(*t)) + // Lavet til en anden med det samme. Det er den samme besked som at blive + // tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej + // ind i "det her er dit nu" giver besked og den anden ikke. + s.notifyAssigned(r, t) writeJSON(w, http.StatusCreated, toTaskJSON(*t)) } @@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { } } + // Læst før ændringen, og kun når der faktisk kommer en tildeling med i den: + // beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den, + // der får en opgave tildelt igen, den allerede har, skal ikke have besked om + // det hver gang nogen retter en overskrift. + wasAssigned := "" + if req.AssigneeID != nil { + if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil { + wasAssigned = before.AssigneeID + } + } + u := store.TaskUpdate{ Description: req.Description, Priority: req.Priority, SectionID: req.SectionID, ParentID: req.ParentID, @@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { } s.activity(r, t.ProjectID, t.ID, "task.updated", nil) s.publish(t.ProjectID, "task.updated", toTaskJSON(*t)) + if req.AssigneeID != nil && t.AssigneeID != wasAssigned { + s.notifyAssigned(r, t) + } writeJSON(w, http.StatusOK, toTaskJSON(*t)) } diff --git a/internal/store/migrations/0033_notifications_about_notes.sql b/internal/store/migrations/0033_notifications_about_notes.sql new file mode 100644 index 0000000..0d6c6c6 --- /dev/null +++ b/internal/store/migrations/0033_notifications_about_notes.sql @@ -0,0 +1,18 @@ +-- En besked kan handle om en note. +-- +-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der +-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en +-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den +-- for at opdage det. +-- +-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er +-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om +-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det +-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes +-- — kan et generisk par slet ikke have. +ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE; + +-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget +-- er nøjagtig det: min, ulæst, om denne note. +CREATE INDEX notifications_unread_by_note + ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL; diff --git a/internal/store/note_shares.go b/internal/store/note_shares.go index c027d99..bfa2b00 100644 --- a/internal/store/note_shares.go +++ b/internal/store/note_shares.go @@ -372,3 +372,41 @@ func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedSha }) return out, nil } + +// NoteAudience is everybody who can see a note: its owner, the people it is shared +// with — chosen or followed along a link — and the members of the project it is +// filed in. +// +// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister, +// der skal lægges sammen bagefter: en person kan både eje projektet og have noten +// delt direkte, og to beskeder om den samme rettelse er én for mange. +// +// The note's own row is joined rather than passed in, so a caller cannot ask about +// a note that has been deleted and get an audience for it anyway. +func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL + UNION + SELECT user_id FROM note_shares WHERE note_id = ? + UNION + SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id + WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL + UNION + SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id + WHERE n.id = ? AND n.deleted_at IS NULL`, + noteID, noteID, noteID, noteID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} diff --git a/internal/store/notifications.go b/internal/store/notifications.go index a392515..852297f 100644 --- a/internal/store/notifications.go +++ b/internal/store/notifications.go @@ -18,6 +18,7 @@ type Notification struct { ActorName string ProjectID string TaskID string + NoteID string Kind string Title string Body string @@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error { n.CreatedAt = time.Now().UTC() _, err := db.ExecContext(ctx, - `INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind, + `INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind, title, body, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID), - nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix()) + nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix()) return err } +// BumpUnreadNoteNotification folds a second change to the same note into the first, +// and says whether it found one to fold into. +// +// A note is saved on a typing pause, so writing a row per save would turn one +// person editing one note for five minutes into a column of twenty identical +// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem +// der rørte den sidst. +// +// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede +// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en +// gentagelse af noget, man allerede har set. +func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) { + res, err := db.ExecContext(ctx, ` + UPDATE notifications + SET created_at = ?, actor_id = ?, title = ?, body = ? + WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`, + time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n > 0, err +} + func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) { if limit <= 0 || limit > 100 { limit = 30 } rows, err := db.QueryContext(ctx, ` SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id, - n.kind, n.title, n.body, n.read_at, n.created_at + n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at FROM notifications n LEFT JOIN users u ON u.id = n.actor_id WHERE n.user_id = ? @@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ( out := []Notification{} for rows.Next() { var n Notification - var actorID, projectID, taskID sql.NullString + var actorID, projectID, taskID, noteID sql.NullString var readAt sql.NullInt64 var created int64 if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID, - &taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil { + &taskID, ¬eID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil { return nil, err } - n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String + n.ActorID, n.ProjectID = actorID.String, projectID.String + n.TaskID, n.NoteID = taskID.String, noteID.String if readAt.Valid { v := time.Unix(readAt.Int64, 0).UTC() n.ReadAt = &v diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte new file mode 100644 index 0000000..b31e0fa --- /dev/null +++ b/web/src/lib/components/Notifications.svelte @@ -0,0 +1,268 @@ + + + { + if (e.key === 'Escape' && open) open = false; + }} +/> + +
+ + + {#if open} + + {/if} +
+ + diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 49193c3..fcc78a5 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -22,6 +22,18 @@ export const da = { 'notes.unshared': 'Noten er din igen.', // Deling med enkeltpersoner, ved siden af projektet. 'notes.groupSharedWithMe': 'Delt med mig', + // Klokken: hvad andre har gjort, som man skal vide. Sætningen skrives i fladen + // og ikke på serveren, så en engelsk flade også får en engelsk klokke. + 'notif.title': 'Beskeder', + 'notif.titleWithCount': 'Beskeder ({n} ulæste)', + 'notif.empty': 'Ingen beskeder.', + 'notif.markAllRead': 'Markér alle som læst', + 'notif.assigned': '{name} gav dig en opgave', + 'notif.noteChanged': '{name} rettede en note', + 'notif.comment': '{name} kommenterede', + 'notif.justNow': 'lige nu', + 'notif.minutes': '{n} min. siden', + 'notif.hours': '{n} t. siden', 'notes.linkOpen': 'Åbn', 'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.', 'notes.sharedBy': 'Delt af {name}', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 1dc6149..390e8d4 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -18,6 +18,19 @@ export const en = { 'notes.unshared': 'The note is yours again.', // Sharing with individual people, alongside the project. 'notes.groupSharedWithMe': 'Shared with me', + // The bell: what other people did that you need to know about. The sentence is + // composed here rather than on the server, so an English interface gets an + // English bell. + 'notif.title': 'Notifications', + 'notif.titleWithCount': 'Notifications ({n} unread)', + 'notif.empty': 'Nothing yet.', + 'notif.markAllRead': 'Mark all as read', + 'notif.assigned': '{name} gave you a task', + 'notif.noteChanged': '{name} edited a note', + 'notif.comment': '{name} commented', + 'notif.justNow': 'just now', + 'notif.minutes': '{n} min ago', + 'notif.hours': '{n} h ago', 'notes.linkOpen': 'Open', 'notes.linkMissing': 'That note does not exist, or it is not shared with you.', 'notes.sharedBy': 'Shared by {name}', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 30b5731..4c1712b 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -108,6 +108,7 @@ class AppState { this.groups = groups; this.people = people; this.connect(); + this.loadNotifications(); } catch (e) { this.user = null; } finally { @@ -145,10 +146,64 @@ class AppState { socket.onerror = () => socket.close(); } + // --- klokken ----------------------------------------------------------------- + // + // Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje: + // hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En + // klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste — + // og en besked, man skal genindlæse for at se, er ikke en besked. + + notifications = $state([]); + unread = $state(0); + + async loadNotifications() { + try { + const r = await api.listNotifications(); + this.notifications = r.notifications ?? []; + this.unread = r.unread ?? 0; + } catch { + // En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for. + } + } + + /** Marks one read, or all of them when no id is given. */ + async markRead(id) { + const previous = this.notifications; + const wasUnread = this.unread; + this.notifications = this.notifications.map((n) => + !id || n.id === id ? { ...n, read: true } : n + ); + this.unread = this.notifications.filter((n) => !n.read).length; + try { + await api.markNotificationsRead(id); + } catch { + this.notifications = previous; + this.unread = wasUnread; + } + } + /** Applies a change made by somebody else. */ #applyRemote(event) { const task = event.payload; switch (event.type) { + // Beskeder kommer på personens egen kanal, ikke projektets: en note delt + // med én person hører ikke til noget projekt, og der er intet rum at + // udsende den i. + case 'notification': + this.notifications = [ + { ...event.payload, read: false }, + ...this.notifications.filter((n) => n.id !== event.payload?.id) + ].slice(0, 30); + this.unread = this.notifications.filter((n) => !n.read).length; + break; + + // En rettelse, der er lagt oven i en besked, man allerede har. Tallet er + // uændret; linjen er en anden. Hentet frem for flettet, fordi serveren + // har det rigtige svar og listen er tredive rækker. + case 'notification.folded': + this.loadNotifications(); + break; + case 'task.created': case 'task.updated': case 'task.completed': diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f498c4f..6f362e6 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,6 +1,7 @@