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 @@