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;
+ }}
+/>
+
+
+
+
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 @@