From d8f5f6e23626bdf33e0ad3830785142b3597eb56 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Tue, 8 Sep 2026 12:08:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(noter):=20n=C3=A6vn=20en=20person=20i=20en?= =?UTF-8?q?=20note,=20og=20den=20er=20delt=20med=20dem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At skrive nogens navn i en note *er* at ville have dem til at læse den. Indtil nu var det to handlinger — skriv navnet, åbn panelet, find personen, del — og den anden blev glemt, så noten stod med en besked til en, der ikke kunne se den. Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem. Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter. Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der hed noget i den retning. Her er der ikke noget at gætte — der står et navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse. En mailadresse er ikke en omtale, det længste navn vinder, og ejefaldets s hører til sætningen. Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som links: en note gemmes ved hver tastepause, så uden sammenligningen var hver gemning en ny deling og en ny besked om det samme navn. Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet følger. Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste, der opfylder sætningen, er det rigtige at give. En rolle, ejeren har hævet i panelet, sænkes ikke igen af en omtale. Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte dig" og ikke også "rettede en note" om den samme gemning. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 11 ++ internal/httpapi/mcp_handlers.go | 4 +- internal/httpapi/notes_handlers.go | 109 +++++++++++++++- internal/httpapi/notes_mention_test.go | 132 ++++++++++++++++++++ internal/httpapi/notifications_handlers.go | 7 +- internal/store/mentions.go | 116 +++++++++++++++++ internal/store/mentions_test.go | 54 ++++++++ web/src/lib/components/NoteEditor.svelte | 40 +++++- web/src/lib/components/Notifications.svelte | 1 + web/src/lib/locales/da.js | 5 + web/src/lib/locales/en.js | 5 + web/src/routes/noter/+page.svelte | 50 ++++++++ 12 files changed, 528 insertions(+), 6 deletions(-) create mode 100644 internal/httpapi/notes_mention_test.go create mode 100644 internal/store/mentions.go create mode 100644 internal/store/mentions_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 400ac6c..853fca7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3017,6 +3017,12 @@ paths: post: tags: [notes] summary: Write a note. + description: > + `#Projekt` files the note against a project and `[[Another note]]` links to + one. A third, `@Name`, shares the note with that person as a viewer and + tells them — only the owner's mentions share, and only a name that has just + appeared in the text counts, so a note saved on every typing pause does not + share itself twice. requestBody: required: true content: @@ -3080,6 +3086,11 @@ paths: patch: tags: [notes] summary: Change a note. Every field is optional; what is sent is what changes. + description: > + A name written with an `@` that was not in the text before shares the note + with that person as a viewer and tells them. Only the owner's mentions + share — an editor may write the name, but giving somebody else's note away + is the owner's alone — and a role already granted is never lowered by one. requestBody: required: true content: diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index c682355..2039ce1 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -527,7 +527,9 @@ func (s *Server) registerNoteTools(m *mcp.Server) { "is lost. Two things in the text are read out of it and become links: " + "`#Projekt` files the note against that project, so it appears on the " + "project's page without anybody filing it there, and `[[Another note]]` " + - "links to a note by its title. Use this to bring notes in from elsewhere — " + + "links to a note by its title. A third, `@Navn`, shares the note with that " + + "person and tells them — so only write one when the note is meant for them. " + + "Use this to bring notes in from elsewhere — " + "Apple Notes, OneNote, a folder of Markdown — one call per note.", InputSchema: mcp.Schema(map[string]any{ "body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."), diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 5ede579..4bbae63 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -215,6 +215,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) { // tegnet væk. Målt i e2e-prøven for monospace, der begyndte at tabe sin titel. if strings.TrimSpace(n.Body) != "" { s.followLinks(r, n) + s.shareWithMentioned(r, n, "") } writeJSON(w, http.StatusCreated, n) } @@ -247,6 +248,108 @@ func (s *Server) followLinks(r *http.Request, n *store.Note) { } } +// shareWithMentioned deler noten med dem, der er skrevet ind i den med et @. +// +// At nævne nogen i en note *er* at ville have dem til at læse den. Indtil nu var +// det to handlinger — skriv navnet, åbn panelet, find personen, del — og den +// anden blev glemt, så noten stod med en besked til en, der ikke kunne se den. +// +// Kun nye omtaler. Sammenlignet med teksten før gemningen på nøjagtig samme måde +// som links, fordi en note gemmes ved hver tastepause: uden sammenligningen ville +// hver eneste gemning være en ny deling og en ny besked om det samme navn. +// +// Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den +// videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele +// ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet +// følger: kun ejeren deler. En medredaktørs omtale er derfor tekst, og den giver +// kun besked til en, der kan se noten i forvejen. +// +// Læser, ikke medredaktør. En omtale siger "se på det her", og det mindste, der +// opfylder sætningen, er det rigtige at give — resten kan ejeren hæve i panelet, +// og en rolle, der er hævet dér, sænkes ikke igen af en omtale. +// Returnerer dem, der fik besked om omtalen, så den samme gemning ikke også +// fortæller dem, at noten er blevet rettet. +func (s *Server) shareWithMentioned(r *http.Request, n *store.Note, before string) map[string]bool { + told := map[string]bool{} + if n == nil || n.CreatedBy == "" { + return told + } + // Slået op mod alle konti på instansen — det er dem, noten kan deles med, og + // derfor dem, et navn kan betyde. Ejeren selv er ikke iblandt: en note kan ikke + // deles med den, der har den. + people, err := s.db.UsersForSharing(r.Context(), n.CreatedBy) + if err != nil { + s.log.Warn("people for mentions", "err", err, "note", n.ID) + return told + } + + was := map[string]bool{} + for _, id := range store.MentionsIn(before, people) { + was[id] = true + } + fresh := []string{} + for _, id := range store.MentionsIn(n.Body, people) { + if !was[id] { + fresh = append(fresh, id) + } + } + if len(fresh) == 0 { + return told + } + + actor := userFrom(r.Context()) + owner := actor.ID == n.CreatedBy + shared := false + for _, id := range fresh { + if !owner { + continue + } + // En rolle, der allerede er sat, står. Omtalen skal give adgang til den, der + // ikke har nogen — ikke sætte en medredaktør ned til læser, fordi ejeren + // skrev deres navn i teksten. + if _, has, err := s.db.NoteShareRole(r.Context(), n.ID, id); err != nil { + s.log.Warn("mention share role", "err", err, "note", n.ID) + continue + } else if has { + continue + } + if err := s.db.ShareNote(r.Context(), n.ID, id, store.RoleViewer, actor.ID); err != nil { + s.log.Warn("share with mentioned", "err", err, "note", n.ID) + continue + } + shared = true + } + if shared { + // Og det, noten peger på, følger med delingen — samme vej som en deling + // sat i panelet. + s.followLinks(r, n) + } + + // Besked til dem, der kan se noten. Efter delingen, så den, omtalen lige har + // givet adgang, er med — og kun til dem, så en medredaktørs omtale af en + // fremmed ikke bliver en besked om en note, de ikke kan åbne. + audience, err := s.db.NoteAudience(r.Context(), n.ID) + if err != nil { + s.log.Warn("note audience for mention", "err", err, "note", n.ID) + return told + } + canSee := map[string]bool{} + for _, id := range audience { + canSee[id] = true + } + for _, id := range fresh { + if !canSee[id] { + continue + } + s.notify(r, &store.Notification{ + UserID: id, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID, + Kind: "note.mention", Title: actor.Name + " nævnte dig i en note", Body: n.Title, + }) + told[id] = true + } + return told +} + func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { var body noteBody if err := decodeJSON(w, r, &body); err != nil { @@ -310,7 +413,11 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) { // 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) + // Omtalerne først. Den, der lige er blevet nævnt, skal have "nævnte dig" og + // ikke også "rettede en note" om den samme gemning — det er én handling, og + // den ene sætning siger mere end den anden. + mentioned := s.shareWithMentioned(r, n, before) + s.notifyNoteChanged(r, n, mentioned) } writeJSON(w, http.StatusOK, n) } diff --git a/internal/httpapi/notes_mention_test.go b/internal/httpapi/notes_mention_test.go new file mode 100644 index 0000000..a65d088 --- /dev/null +++ b/internal/httpapi/notes_mention_test.go @@ -0,0 +1,132 @@ +package httpapi + +import ( + "net/http" + "testing" +) + +// At nævne nogen i en note er at ville have dem til at læse den: omtalen deler +// noten, og den, der bliver nævnt, får besked om det. +func TestMentioningSomebodySharesTheNote(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\nikke noget endnu") + if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusNotFound { + t.Fatalf("noten var læselig før omtalen: %d", resp.StatusCode) + } + + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale om levering\n@Sofie kan du kigge på det?"}); resp.StatusCode != http.StatusOK { + t.Fatalf("ret noten: %d", resp.StatusCode) + } + + // Delt, som læser. + role, has, err := owner.db.NoteShareRole(t.Context(), noteID, sofie) + if err != nil || !has || role != "viewer" { + t.Fatalf("deling = %v (findes: %v, fejl: %v), want viewer", role, has, err) + } + if resp, _ := other.do(t, "GET", "/api/v1/notes/"+noteID, nil); resp.StatusCode != http.StatusOK { + t.Fatalf("den omtalte kan ikke læse noten: %d", resp.StatusCode) + } + + // Og sagt til — med omtalen som beskeden, ikke "rettede en note". Det er én + // handling, og den ene sætning siger mere end den anden. + list, unread := notifications(t, other) + if len(list) != 1 || unread != 1 { + t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread) + } + if n := firstOf(t, list); n["kind"] != "note.mention" || n["note_id"] != noteID { + t.Errorf("besked = %v", n) + } +} + +// En gemning mere med det samme navn i er ikke en omtale mere. En note gemmes ved +// hver tastepause, så uden det bliver fem minutters skriveri til tyve beskeder. +func TestTheSameMentionOnlyCountsOnce(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + other := owner.newUser(t, "sofie@example.dk", "Sofie") + + noteID := owner.createNote(t, "# Aftale\n@Sofie kigger") + // Omtalen stod i noten fra begyndelsen: den tæller ved oprettelsen. + if list, _ := notifications(t, other); len(list) != 1 { + t.Fatalf("en omtale i en ny note gav %d beskeder, want 1", len(list)) + } + + for i := 0; i < 3; i++ { + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale\n@Sofie kigger på linje " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten") + } + } + // Tre gemninger mere: én besked om rettelserne, slået sammen, og ikke tre + // omtaler mere. + list, _ := notifications(t, other) + if len(list) != 2 { + t.Fatalf("tre gemninger med det samme navn gav %d beskeder, want 2", len(list)) + } + if firstOf(t, list)["kind"] != "note.changed" { + t.Errorf("den nyeste besked = %v, want note.changed", firstOf(t, list)) + } +} + +// En medredaktør må skrive i noten, men ikke give den videre. Ellers kunne den, +// ejeren har delt med, skrive et navn og dele ejerens note med en fremmed. +func TestAnEditorsMentionDoesNotShareTheNote(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + editor := owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + peterClient := owner.newUser(t, "peter@example.dk", "Peter") + peter := userID(t, owner, "peter@example.dk") + + noteID := owner.createNote(t, "# Aftale\nintet") + 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") + } + + if resp, _ := editor.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale\n@Peter ved det"}); resp.StatusCode != http.StatusOK { + t.Fatal("medredaktøren retter") + } + + if _, has, err := owner.db.NoteShareRole(t.Context(), noteID, peter); err != nil || has { + t.Errorf("en medredaktørs omtale delte ejerens note (findes: %v, fejl: %v)", has, err) + } + // Og Peter får ikke besked om en note, han ikke kan åbne. + if list, _ := notifications(t, peterClient); len(list) != 0 { + t.Errorf("Peter fik %d beskeder om en note, han ikke kan se", len(list)) + } + // Ejeren hører derimod, at der er skrevet i noten — det er stadig en rettelse. + if list, _ := notifications(t, owner); len(list) == 0 { + t.Error("ejeren hørte ikke, at medredaktøren skrev i noten") + } +} + +// Rollen står. En omtale skal give adgang til den, der ikke har nogen — ikke +// sætte en medredaktør ned til læser, fordi ejeren skrev deres navn. +func TestAMentionDoesNotLowerAnExistingRole(t *testing.T) { + owner := newTestServer(t) + owner.bootstrap(t) + owner.newUser(t, "sofie@example.dk", "Sofie") + sofie := userID(t, owner, "sofie@example.dk") + + noteID := owner.createNote(t, "# Aftale\nintet") + 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") + } + if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID, + map[string]any{"body": "# Aftale\n@Sofie retter selv"}); resp.StatusCode != http.StatusOK { + t.Fatal("ret noten") + } + + role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie) + if err != nil || role != "editor" { + t.Errorf("rolle = %v (%v), want editor uændret", role, err) + } +} diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go index 322c7ae..2fb625a 100644 --- a/internal/httpapi/notifications_handlers.go +++ b/internal/httpapi/notifications_handlers.go @@ -143,10 +143,13 @@ func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) // notifyNoteChanged tells everybody who can see a note that somebody else wrote in // it — folded into one line per note while it is unread. // +// `except` er dem, den samme gemning allerede har sagt noget mere præcist til: +// den, der lige er blevet nævnt ved navn, skal ikke også høre, at noten er rettet. +// // 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) { +func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note, except map[string]bool) { if n == nil { return } @@ -159,7 +162,7 @@ func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) { title := actor.Name + " rettede en note" body := n.Title for _, userID := range audience { - if userID == actor.ID { + if userID == actor.ID || except[userID] { continue } // Fold first, and only write a row when there was nothing to fold into. diff --git a/internal/store/mentions.go b/internal/store/mentions.go new file mode 100644 index 0000000..add7c4f --- /dev/null +++ b/internal/store/mentions.go @@ -0,0 +1,116 @@ +package store + +import ( + "sort" + "strings" + "unicode" +) + +// MentionsIn reads the people named with an @ out of a body, and returns their +// ids in the order they are written. +// +// Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum, +// bindestreg og punktum i sig, og et udtryk, der skulle fange dem alle, ville +// også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der +// hedder "Sofie i morgen", og ellers til "Sofie", alt efter hvor grådigt det var +// skrevet. Her er der ikke noget at gætte — der står et navn, eller der gør ikke. +// +// Det er også dét, der gør det forsvarligt at *dele* på: en omtale giver adgang, +// og adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse. +// +// Længste navn først, så "@Sofie Jensen" bliver Sofie Jensen og ikke Sofie med et +// efternavn hængende bagefter — også når begge findes som konti. +func MentionsIn(body string, people []Person) []string { + if body == "" || len(people) == 0 { + return nil + } + lower := strings.ToLower(body) + + byLength := make([]Person, len(people)) + copy(byLength, people) + sort.SliceStable(byLength, func(i, j int) bool { + return len(byLength[i].Name) > len(byLength[j].Name) + }) + + // Hvilke tegn der allerede er læst som en del af en omtale. Uden det ville + // "@Sofie Jensen" også tælle som en omtale af Sofie, hvis begge er konti — og + // noten ville blive delt med en, der ikke står i den. + claimed := make([]bool, len(body)) + found := map[string]int{} // bruger -> hvor i teksten, så rækkefølgen kan bæres + + for _, p := range byLength { + name := strings.ToLower(strings.TrimSpace(p.Name)) + if name == "" || p.ID == "" { + continue + } + needle := "@" + name + for at := 0; ; { + i := strings.Index(lower[at:], needle) + if i < 0 { + break + } + i += at + at = i + len(needle) + + end := i + len(needle) + if overlaps(claimed, i, end) || !isMention(body, i, end) { + continue + } + for j := i; j < end; j++ { + claimed[j] = true + } + if _, seen := found[p.ID]; !seen { + found[p.ID] = i + } + } + } + + out := make([]string, 0, len(found)) + for id := range found { + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return found[out[i]] < found[out[j]] }) + return out +} + +func overlaps(claimed []bool, from, to int) bool { + for i := from; i < to && i < len(claimed); i++ { + if claimed[i] { + return true + } + } + return false +} + +// isMention er de to kanter om navnet. +// +// Foran: begyndelsen af noten eller noget, der ikke er en bogstavstavelse — så en +// e-mailadresse ikke bliver til en omtale, fordi der tilfældigvis står et navn +// efter snabel-a'et. +// +// Bagved: ikke midt i et længere ord. "@Sofies kalender" er skrevet om Sofie og +// tæller med — ejefaldets s hører til sætningen, ikke til en anden person — men +// "@Sofienberg" er et sted og ikke en, der skal have noten delt. Grænsen er sat +// ved, at et bogstav *lige* efter navnet kun må være et ejefalds-s. +func isMention(body string, from, to int) bool { + runes := []rune(body[:from]) + if len(runes) > 0 { + prev := runes[len(runes)-1] + if unicode.IsLetter(prev) || unicode.IsDigit(prev) || prev == '@' { + return false + } + } + + rest := body[to:] + if rest == "" { + return true + } + next := []rune(rest) + if next[0] == 's' || next[0] == 'S' { + next = next[1:] + if len(next) == 0 { + return true + } + } + return !unicode.IsLetter(next[0]) && !unicode.IsDigit(next[0]) +} diff --git a/internal/store/mentions_test.go b/internal/store/mentions_test.go new file mode 100644 index 0000000..8196142 --- /dev/null +++ b/internal/store/mentions_test.go @@ -0,0 +1,54 @@ +package store + +import ( + "reflect" + "testing" +) + +func TestMentionsIn(t *testing.T) { + people := []Person{ + {ID: "sofie", Name: "Sofie"}, + {ID: "sofie-jensen", Name: "Sofie Jensen"}, + {ID: "kristian", Name: "Kristian"}, + } + + for _, tc := range []struct { + name string + body string + want []string + }{ + {"ingen omtale", "En helt almindelig note om noget", nil}, + {"én omtale", "@Sofie kan du kigge på det her?", []string{"sofie"}}, + {"midt i en sætning", "Aftalt med @Kristian i går", []string{"kristian"}}, + {"uden hensyn til store bogstaver", "@sofie ja", []string{"sofie"}}, + {"to omtaler i den rækkefølge de står", "@Kristian og @Sofie", []string{"kristian", "sofie"}}, + {"den samme to gange er én", "@Sofie ... og @Sofie igen", []string{"sofie"}}, + // Det lange navn vinder, og det korte tælles ikke med oveni: noten deles + // med den, der står i den, og ikke også med en, der ikke gør. + {"længste navn vinder", "@Sofie Jensen kigger på det", []string{"sofie-jensen"}}, + // En adresse er ikke en omtale. Ellers ville enhver note med en mailadresse + // i sig dele sig selv med den, der tilfældigvis hedder noget efter snabel-a. + {"e-mailadresse", "skriv til hej@Sofie.dk", nil}, + {"ejefald tæller med", "det står i @Sofies kalender", []string{"sofie"}}, + {"et længere ord gør ikke", "vi mødtes på @Sofienberg", nil}, + {"ukendt navn", "@Ingen her", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := MentionsIn(tc.body, people) + if len(got) == 0 && len(tc.want) == 0 { + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("MentionsIn(%q) = %v, want %v", tc.body, got, tc.want) + } + }) + } +} + +// Ingen mennesker, ingen omtaler: opslaget sker i navnene, så en tom liste er et +// tomt svar frem for et gæt på, hvad et @ mon betød. +func TestMentionsInWithoutPeople(t *testing.T) { + if got := MentionsIn("@Sofie hej", nil); got != nil { + t.Errorf("got %v, want nothing to resolve against", got) + } +} diff --git a/web/src/lib/components/NoteEditor.svelte b/web/src/lib/components/NoteEditor.svelte index e755200..965e7fa 100644 --- a/web/src/lib/components/NoteEditor.svelte +++ b/web/src/lib/components/NoteEditor.svelte @@ -20,7 +20,10 @@ import { api, humanMessage } from '$lib/api.js'; import { colorVar } from '$lib/colors.js'; - let { note, notes = [], onchange, onsave, onopennote } = $props(); + // `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem + // for hentet her, fordi siden allerede har listen — den står i delepanelet — og + // to hentninger af den samme liste er to, der kan blive uenige. + let { note, notes = [], people = [], onchange, onsave, onopennote } = $props(); let editor; // Forslagslisten, så tastaturvalget kan rulle den frem. @@ -87,6 +90,7 @@ const SYNTAX = [ { mark: '#', what: 'notes.syntaxProject' }, { mark: '[[', what: 'notes.syntaxNote' }, + { mark: '@', what: 'notes.syntaxPerson' }, { mark: '⌘B', what: 'notes.bold' }, { mark: '⌘U', what: 'notes.underline' } ]; @@ -509,6 +513,19 @@ return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] }; } + // Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med: + // "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville + // læse sætningen efter navnet med. + const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before); + if (person) { + return { + kind: 'person', + node, + start: selection.anchorOffset - person[1].length, + term: person[1] + }; + } + const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before); if (project) { return { @@ -562,7 +579,12 @@ // pixels for hvert bogstav, mens listen snævres ind. if (!suggestions.length) menuAt = tagPosition(partial); - suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term); + suggestions = + partial.kind === 'note' + ? noteMatches(term) + : partial.kind === 'person' + ? personMatches(term) + : projectMatches(term); chosen = 0; } @@ -586,6 +608,16 @@ .sort((a, b) => rank(a.label, b.label, term)); } + // Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at + // nævne en, der læser med i forvejen, er en almindelig ting at gøre — og + // omtalen giver dem besked, selv om den ikke giver dem noget nyt. + function personMatches(term) { + return people + .filter((p) => p.name && p.name.toLowerCase().includes(term)) + .map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color })) + .sort((a, b) => rank(a.label, b.label, term)); + } + function rank(a, b, term) { const al = a.toLowerCase(); const bl = b.toLowerCase(); @@ -1011,6 +1043,10 @@ {#if item.kind === 'note'} {item.label} + {:else if item.kind === 'person'} + + @{item.label} {:else} diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte index d1b5926..e06746f 100644 --- a/web/src/lib/components/Notifications.svelte +++ b/web/src/lib/components/Notifications.svelte @@ -24,6 +24,7 @@ assigned: 'notif.assigned', 'note.changed': 'notif.noteChanged', 'note.shared': 'notif.noteShared', + 'note.mention': 'notif.mention', comment: 'notif.comment' }; diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 5f5aaf8..1e8e2a4 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -31,6 +31,7 @@ export const da = { 'notif.assigned': '{name} gav dig en opgave', 'notif.noteChanged': '{name} rettede en note', 'notif.noteShared': '{name} delte en note med dig', + 'notif.mention': '{name} nævnte dig i en note', 'notif.comment': '{name} kommenterede', 'notif.justNow': 'lige nu', 'notif.minutes': '{n} min. siden', @@ -62,6 +63,10 @@ export const da = { 'notes.inviteRevoke': 'Træk invitationen tilbage', 'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.', 'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:', + // Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her, + // fordi det sker uden at nogen åbner delepanelet. + 'notes.syntaxPerson': 'en person', + 'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.', 'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.', 'notes.save': 'Gem', 'notes.format': 'Formatér', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index c14cd53..4ff0d8b 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -28,6 +28,7 @@ export const en = { 'notif.assigned': '{name} gave you a task', 'notif.noteChanged': '{name} edited a note', 'notif.noteShared': '{name} shared a note with you', + 'notif.mention': '{name} mentioned you in a note', 'notif.comment': '{name} commented', 'notif.justNow': 'just now', 'notif.minutes': '{n} min ago', @@ -59,6 +60,10 @@ export const en = { 'notes.inviteRevoke': 'Withdraw the invitation', 'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.', 'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:', + // A mention. `@Name` in a note shares it with the person named — said here, + // because it happens without anybody opening the share panel. + 'notes.syntaxPerson': 'a person', + 'notes.mentionShared': 'The note has been shared with {names}, because you named them.', 'notes.shareOwnerOnly': 'Only the owner can share the note.', 'notes.save': 'Save', 'notes.format': 'Format', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 389da12..ea3cdb7 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -209,6 +209,21 @@ let shareInviteLink = $state(''); const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id); + // Dem, et `@` kan betyde i den åbne note. + // + // I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt + // med: en omtale af en, der læser med i forvejen, er en almindelig ting at + // skrive, og den giver dem besked selv om den ikke giver dem adgang. + // + // I en andens note er det dem, man deler projekter med. En medredaktørs omtale + // deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens, + // og skal heller ikke være det. + const mentionable = $derived( + ownsSelected + ? [...shareCandidates, ...shares.map((sh) => sh.user)] + : app.people.filter((p) => p.id !== app.user?.id) + ); + // The whole of sharing — a project and named people — lives in a popover behind // one button, so the note's footer stays a status and three buttons rather than // a row that runs off its own width. @@ -659,6 +674,7 @@ // Only if it is still the one on screen: switching notes mid-save must not // drag the previous one's title back onto this one. if (selected?.id === id) selected = saved; + await mentionsShared(id); } catch (e) { app.toast(humanMessage(e)); } finally { @@ -666,6 +682,39 @@ } } + /** + * Hvem gemningen delte noten med, fordi de blev nævnt i den. + * + * Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting + * blandt mange gemninger, og en tastepause skal ikke koste et opslag i + * delingerne, hver gang den falder. Panelet spørges i stedet — og kun når + * teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv + * bruger for at kigge efter. + * + * Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man + * skrev navnet på, skal sige det — også når panelet er lukket. + */ + async function mentionsShared(id) { + if (!ownsSelected || !draft.includes('@')) return; + const known = new Set(shares.map((sh) => sh.user.id)); + try { + const r = await api.noteShares(id); + if (selected?.id !== id) return; + shares = r.shares ?? []; + shareCandidates = r.candidates ?? []; + shareFollows = r.follows ?? []; + shareInvites = r.invites ?? []; + const added = shares.filter((sh) => !known.has(sh.user.id)); + if (added.length) { + app.toast( + t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') }) + ); + } + } catch { + // En liste, der ikke kunne hentes, er ikke en gemning, der gik galt. + } + } + /** * Favourites are the pinned ones. The list already puts them first, so marking * one is the same act as saying "keep this where I can see it" — one idea, not @@ -1028,6 +1077,7 @@ openLink({ kind: 'note', target_id: title })} onchange={(body) => { draft = body;