Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
This commit is contained in:
+11
-1
@@ -4560,12 +4560,22 @@ components:
|
|||||||
|
|
||||||
Notification:
|
Notification:
|
||||||
type: object
|
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:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
actor_name: { type: string }
|
actor_name: { type: string }
|
||||||
project_id: { type: string }
|
project_id: { type: string }
|
||||||
task_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 }
|
title: { type: string }
|
||||||
body: { type: string }
|
body: { type: string }
|
||||||
read: { type: boolean }
|
read: { type: boolean }
|
||||||
|
|||||||
@@ -306,6 +306,12 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
|
||||||
s.followLinks(r, n)
|
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)
|
writeJSON(w, http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type notificationJSON struct {
|
|||||||
ActorName string `json:"actor_name,omitempty"`
|
ActorName string `json:"actor_name,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
TaskID string `json:"task_id,omitempty"`
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
NoteID string `json:"note_id,omitempty"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
|||||||
for _, n := range list {
|
for _, n := range list {
|
||||||
out = append(out, notificationJSON{
|
out = append(out, notificationJSON{
|
||||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
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,
|
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
|
||||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
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{
|
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
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
|
// 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,
|
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||||
// which has the schedule but not the keys.
|
// which has the schedule but not the keys.
|
||||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
t.Labels = req.Labels
|
t.Labels = req.Labels
|
||||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
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))
|
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{
|
u := store.TaskUpdate{
|
||||||
Description: req.Description, Priority: req.Priority,
|
Description: req.Description, Priority: req.Priority,
|
||||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
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.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
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))
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -372,3 +372,41 @@ func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedSha
|
|||||||
})
|
})
|
||||||
return out, nil
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Notification struct {
|
|||||||
ActorName string
|
ActorName string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
TaskID string
|
TaskID string
|
||||||
|
NoteID string
|
||||||
Kind string
|
Kind string
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
@@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
|||||||
n.CreatedAt = time.Now().UTC()
|
n.CreatedAt = time.Now().UTC()
|
||||||
|
|
||||||
_, err := db.ExecContext(ctx,
|
_, 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)
|
title, body, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
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
|
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) {
|
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
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
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
LEFT JOIN users u ON u.id = n.actor_id
|
||||||
WHERE n.user_id = ?
|
WHERE n.user_id = ?
|
||||||
@@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
|||||||
out := []Notification{}
|
out := []Notification{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Notification
|
var n Notification
|
||||||
var actorID, projectID, taskID sql.NullString
|
var actorID, projectID, taskID, noteID sql.NullString
|
||||||
var readAt sql.NullInt64
|
var readAt sql.NullInt64
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
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
|
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 {
|
if readAt.Valid {
|
||||||
v := time.Unix(readAt.Int64, 0).UTC()
|
v := time.Unix(readAt.Int64, 0).UTC()
|
||||||
n.ReadAt = &v
|
n.ReadAt = &v
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Klokken.
|
||||||
|
*
|
||||||
|
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
|
||||||
|
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
|
||||||
|
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
|
||||||
|
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
|
||||||
|
* delt projekt, er en klokke, folk holder op med at se på.
|
||||||
|
*
|
||||||
|
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
|
||||||
|
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
|
||||||
|
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
|
||||||
|
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
|
||||||
|
*/
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let wrap = $state(null);
|
||||||
|
|
||||||
|
const KINDS = {
|
||||||
|
assigned: 'notif.assigned',
|
||||||
|
'note.changed': 'notif.noteChanged',
|
||||||
|
comment: 'notif.comment'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
|
||||||
|
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
|
||||||
|
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
|
||||||
|
|
||||||
|
function when(iso) {
|
||||||
|
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
|
||||||
|
if (minutes < 1) return t('notif.justNow');
|
||||||
|
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
|
||||||
|
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor en besked fører hen.
|
||||||
|
*
|
||||||
|
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
|
||||||
|
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
|
||||||
|
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
|
||||||
|
* man går hen.
|
||||||
|
*/
|
||||||
|
function follow(n) {
|
||||||
|
open = false;
|
||||||
|
if (!n.read) app.markRead(n.id);
|
||||||
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
||||||
|
else if (n.task_id) app.openDetail(n.task_id);
|
||||||
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outside(event) {
|
||||||
|
if (open && wrap && !wrap.contains(event.target)) open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onclick={outside}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open) open = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap" bind:this={wrap}>
|
||||||
|
<button
|
||||||
|
class="bell"
|
||||||
|
class:on={open}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={app.unread
|
||||||
|
? t('notif.titleWithCount', { n: String(app.unread) })
|
||||||
|
: t('notif.title')}
|
||||||
|
title={t('notif.title')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10.3 21a2 2 0 003.4 0" />
|
||||||
|
</svg>
|
||||||
|
{#if app.unread}
|
||||||
|
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
|
||||||
|
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
|
||||||
|
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
||||||
|
<header>
|
||||||
|
<span>{t('notif.title')}</span>
|
||||||
|
{#if app.unread}
|
||||||
|
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if app.notifications.length}
|
||||||
|
<ul>
|
||||||
|
{#each app.notifications as n (n.id)}
|
||||||
|
<li>
|
||||||
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
||||||
|
<span class="what">{headline(n)}</span>
|
||||||
|
{#if n.body}<span class="about">{n.body}</span>{/if}
|
||||||
|
<span class="ago">{when(n.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="none">{t('notif.empty')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bell:hover,
|
||||||
|
.bell.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.bell svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 15px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent, #fff);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
width: min(340px, calc(100vw - var(--s3) * 2));
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.allread {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.allread:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
.row:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
|
||||||
|
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
|
||||||
|
kunne se hvad der var én besked. */
|
||||||
|
.row.unread .what {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.row.unread .what::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.what {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ago {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3) var(--s2);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,6 +22,18 @@ export const da = {
|
|||||||
'notes.unshared': 'Noten er din igen.',
|
'notes.unshared': 'Noten er din igen.',
|
||||||
// Deling med enkeltpersoner, ved siden af projektet.
|
// Deling med enkeltpersoner, ved siden af projektet.
|
||||||
'notes.groupSharedWithMe': 'Delt med mig',
|
'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.linkOpen': 'Åbn',
|
||||||
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
|
||||||
'notes.sharedBy': 'Delt af {name}',
|
'notes.sharedBy': 'Delt af {name}',
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ export const en = {
|
|||||||
'notes.unshared': 'The note is yours again.',
|
'notes.unshared': 'The note is yours again.',
|
||||||
// Sharing with individual people, alongside the project.
|
// Sharing with individual people, alongside the project.
|
||||||
'notes.groupSharedWithMe': 'Shared with me',
|
'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.linkOpen': 'Open',
|
||||||
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
|
||||||
'notes.sharedBy': 'Shared by {name}',
|
'notes.sharedBy': 'Shared by {name}',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class AppState {
|
|||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
this.people = people;
|
this.people = people;
|
||||||
this.connect();
|
this.connect();
|
||||||
|
this.loadNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,10 +146,64 @@ class AppState {
|
|||||||
socket.onerror = () => socket.close();
|
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. */
|
/** Applies a change made by somebody else. */
|
||||||
#applyRemote(event) {
|
#applyRemote(event) {
|
||||||
const task = event.payload;
|
const task = event.payload;
|
||||||
switch (event.type) {
|
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.created':
|
||||||
case 'task.updated':
|
case 'task.updated':
|
||||||
case 'task.completed':
|
case 'task.completed':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||||
|
import Notifications from '$lib/components/Notifications.svelte';
|
||||||
import { connectivity } from '$lib/connectivity.svelte.js';
|
import { connectivity } from '$lib/connectivity.svelte.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -169,6 +170,8 @@
|
|||||||
<kbd>⌘K</kbd>
|
<kbd>⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Notifications />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="theme"
|
class="theme"
|
||||||
onclick={() => theme.toggle()}
|
onclick={() => theme.toggle()}
|
||||||
|
|||||||
Reference in New Issue
Block a user