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;