Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 d8f5f6e236 feat(noter): nævn en person i en note, og den er delt med dem
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 <noreply@anthropic.com>
2026-09-08 12:08:12 +02:00
kristianwindandClaude Opus 5 8ee9fbedfb feat(noter): del en note med en, der ikke har en konto endnu
En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:58:47 +02:00
21 changed files with 1386 additions and 60 deletions
+81 -1
View File
@@ -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:
@@ -3126,6 +3137,13 @@ paths:
candidates:
type: array
items: { $ref: "#/components/schemas/Person" }
invites:
description: >
Addresses invited to the note that have not been taken up yet.
There is no account behind them, so there is no name or colour
to show — the address is all there is until they arrive.
type: array
items: { $ref: "#/components/schemas/NoteInvite" }
follows:
description: >
The notes this one points at with [[links]], which are shared
@@ -3137,6 +3155,11 @@ paths:
tags: [notes]
summary: Share a note with a person, or change the role they hold.
description: >
The person is named either by `user_id` — somebody picked from the
candidates — or by `email`. An address that already has an account is
shared with straight away; one that does not gets an invitation, which
becomes the share the moment the account is created (201).
The notes this one points at with [[links]] are shared with the same person
and the same role, so a link in a shared note leads somewhere. Only notes
the sharer owns follow, and the response lists them.
@@ -3146,9 +3169,10 @@ paths:
application/json:
schema:
type: object
required: [user_id]
description: One of user_id or email.
properties:
user_id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
responses:
"200":
@@ -3158,12 +3182,58 @@ paths:
schema:
type: object
properties:
user:
description: >
Who it was shared with. Sent back because a caller that shared
by address does not otherwise know who it reached.
$ref: "#/components/schemas/Person"
role: { type: string, enum: [viewer, editor] }
follows:
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"201":
description: >
Invited. The address has no account yet, so a link that creates one and
the share together has been issued.
content:
application/json:
schema:
type: object
properties:
invited: { $ref: "#/components/schemas/NoteInvite" }
link:
description: The signup link, shown so it can be passed on.
type: string
emailed:
description: >
False when the instance has no mail configured, which is when
the link is the only way the invitation reaches anybody.
type: boolean
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/notes/{noteID}/invites/{inviteID}:
delete:
tags: [notes]
summary: Withdraw an invitation to a note before it is used.
description: >
The only way back. Only the token's hash is stored, so a link sent to the
wrong address cannot be looked up and invalidated — only the row it belongs
to can.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
- name: inviteID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Withdrawn }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/notes/{noteID}/shares/{userID}:
delete:
tags: [notes]
@@ -4072,6 +4142,16 @@ components:
note_id: { type: string }
title: { type: string }
NoteInvite:
type: object
description: >
A share on a note that is waiting for an account. The address is the whole
of who the person is until they sign up.
properties:
id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [viewer, editor] }
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
+3 -1
View File
@@ -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."),
+108 -1
View File
@@ -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)
}
+186
View File
@@ -0,0 +1,186 @@
package httpapi
import (
"net/http"
"strings"
"testing"
)
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "sofie@example.dk", "role": "editor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
}
invited, _ := body["invited"].(map[string]any)
if invited == nil || invited["email"] != "sofie@example.dk" {
t.Fatalf("the response does not say who was invited: %v", body)
}
if invited["role"] != "editor" {
t.Errorf("role = %v, want the one the owner chose", invited["role"])
}
// Uden en postserver er linket det eneste, der findes, og det skal stå i
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
link, _ := body["link"].(string)
if !strings.Contains(link, "token=") {
t.Fatalf("link = %q, want one carrying a token", link)
}
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
if len(pending) != 1 {
t.Fatalf("want one pending invite under the note, got %v", pending)
}
// Og linket laver kontoen og delingen i ét.
token := link[strings.Index(link, "token=")+len("token="):]
fresh := newSignedOut(t, owner)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Sofie", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
}
// Noten er hendes at læse — og som medredaktør også at rette.
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
}
if note["title"] != "Aftale om levering" {
t.Errorf("the invited person got something else: %v", note)
}
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
}
// Og invitationen er brugt op: den står ikke længere og venter.
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
if left, _ := after["invites"].([]any); len(left) != 0 {
t.Errorf("the invite is still pending after it was accepted: %v", left)
}
shares, _ := after["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("want the accepted invite to be a share, got %v", shares)
}
}
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
// invitation til at oprette en, som personen aldrig kan komme igennem.
func TestSharingByEmailFindsAnExistingAccount(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, "# Prisliste\n2026")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "Sofie@Example.dk"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
}
person, _ := body["user"].(map[string]any)
if person == nil || person["id"] != sofie {
t.Fatalf("the response does not say who it reached: %v", body)
}
if _, invited := body["invited"]; invited {
t.Error("an address with an account was invited rather than shared with")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "viewer" {
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
}
}
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
// det samme, og kun det ene kan trækkes tilbage ad gangen.
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("first invite: status %d", resp.StatusCode)
}
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
}
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
id := pending[0].(map[string]any)["id"].(string)
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
}
}
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
func TestOnlyTheOwnerCanInviteToANote(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, "# Ferieplan\nuge 29")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "fremmed@example.dk"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
}
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
t.Fatalf("invites = %v (%v), want none", n, err)
}
}
// En invitation til én note kan ikke bruges til at rydde en anden notes
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
mine := owner.createNote(t, "# Min note\n1")
other := owner.createNote(t, "# Min anden note\n2")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatal("invite failed")
}
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
if err != nil || len(invites) != 1 {
t.Fatalf("invites = %v (%v)", invites, err)
}
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
}
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
t.Error("the invite was withdrawn through a note it does not belong to")
}
}
+132
View File
@@ -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)
}
}
+145 -7
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
@@ -128,9 +129,23 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
// den samme indbakke, og ingen af dem til at trække tilbage.
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note invites", err)
return
}
pending := make([]noteInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"shares": out,
"candidates": cand,
"invites": pending,
"follows": followsOf(following, n.ID),
})
}
@@ -167,10 +182,33 @@ type noteShareJSON struct {
type shareNoteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
// Email deles der med, når personen ikke er at finde i listen — enten fordi
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
// finde endnu.
Email string `json:"email"`
Role string `json:"role"`
}
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
// dukker op.
type noteInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
//
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
//
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
// vent på at de opretter sig, find noten frem, del den.
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
var req shareNoteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -194,34 +232,134 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
return
}
if req.UserID == "" {
me := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
return
}
// En adresse, der viser sig at høre til en konto, er den konto. At sende dem
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en blindgyde
// — samme regel som en projektinvitation følger.
if req.UserID == "" {
if !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if email == store.NormalizeEmail(me.Email) {
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
return
}
existing, err := s.db.UserByEmail(r.Context(), email)
if err != nil {
s.inviteToNote(w, r, n, email, role)
return
}
req.UserID = existing.ID
}
// The recipient must be a real account and not the sharer themselves; ShareNote
// refuses the owner, and a made-up id should not reach it.
if req.UserID == userFrom(r.Context()).ID {
if req.UserID == me.ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
person, err := s.db.PersonByID(r.Context(), req.UserID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
return
}
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
s.storeError(w, r, "share note", err)
return
}
s.notifyNoteShared(r, n, req.UserID)
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)})
writeJSON(w, http.StatusOK, map[string]any{
"follows": followsOf(followed, n.ID),
// Personen sendes med retur, fordi fladen kan have delt med en adresse og
// derfor ikke selv ved, hvem den ramte.
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
"role": string(role),
})
}
// inviteToNote sends a link that creates an account and the share at once.
//
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
// på den.
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
me := userFrom(r.Context())
// En anden invitation til den samme note i den samme indbakke er ikke en
// invitation mere: det er to links, der gør det samme, og kun det ene kan
// trækkes tilbage ad gangen.
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
if err != nil {
s.internal(w, r, "pending note invite", err)
return
}
if pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
return
}
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create note invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
s.log.Error("send note invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
"link": link,
"emailed": emailed,
})
}
// handleDeleteNoteInvite withdraws an invitation before it is used.
//
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
// kan kun rækken, det hører til.
func (s *Server) handleDeleteNoteInvite(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete note invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnshareNote takes a person's access away again.
+25 -2
View File
@@ -120,13 +120,36 @@ func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
})
}
// notifyNoteShared tells somebody that a note is now theirs to read.
//
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
//
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
// ikke en ny note at læse.
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
if n == nil || userID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
})
}
// 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
}
@@ -139,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.
+17 -8
View File
@@ -109,14 +109,22 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
t.Fatal("del noten")
}
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
// nogen retter i den. Den tælles med i alt, hvad der følger.
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
firstOf(t, list)["kind"] != "note.shared" {
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
}
// 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)
if unread != 2 || len(list) != 2 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "note.changed" || n["note_id"] != noteID {
@@ -132,8 +140,9 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
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))
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
len(list))
}
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
@@ -144,8 +153,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
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)
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
}
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
@@ -171,8 +180,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
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))
if list, _ := notifications(t, other); len(list) != 3 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
}
}
+3
View File
@@ -353,6 +353,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
// En invitation til en note er en deling, der venter på en konto.
// Den kan tages tilbage indtil den bruges; se handleShareNote.
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
+20 -1
View File
@@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
what := "verdande"
if projectName != "" {
what = "projektet “" + projectName + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
// SendNoteInvite is the same letter about a single note.
//
// The title is in the mail because it is what makes the invitation mean anything —
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
// somebody can decide about, where "til en note" is one they have to click a link
// to understand. It travels no further than the address the owner typed, which is
// the address they chose to share the note with.
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
what := "en note"
if noteTitle != "" {
what = "noten “" + noteTitle + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
+161 -36
View File
@@ -16,6 +16,7 @@ type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
NoteID string // set when the invite is to a single note
Role Role
CreatedBy string
CreatedAt time.Time
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
// CreateInvite issues an invite to a project and returns the token for the emailed
// link. projectID may be empty, which invites somebody to the instance rather than
// to a particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
}
// CreateNoteInvite issues an invite to a single note.
//
// Same row, same token, same link as a project invite — the difference is only
// what the person gets when they arrive, and that is settled in AcceptInvite. A
// second table for "invited to a note" would have meant a second signup path to
// keep in step with this one, and the two would drift the first time either was
// touched.
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if noteID == "" {
return "", nil, errors.New("store: a note invite needs a note")
}
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
}
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
if !inv.Role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
inv.ID = NewID()
inv.Email = NormalizeEmail(inv.Email)
inv.CreatedAt = now
inv.ExpiresAt = now.Add(ttl)
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var projectID, noteID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
Scan(&inv.ID, &inv.Email, &projectID, &noteID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
}
inv.ProjectID = projectID.String
inv.NoteID = noteID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
@@ -144,10 +156,16 @@ type PendingInvite struct {
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
// NoteID is set for an invitation to a single note. The note is named by its
// id and never by its title: titles are sealed in the database, and a private
// note's first line is not something an administrator's user list should
// spell out. That there is an account on its way is the administrator's
// business; what it was invited to read is not.
NoteID string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
@@ -159,7 +177,8 @@ type PendingInvite struct {
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
COALESCE(i.note_id, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// AcceptInvite marks the invite used and grants what it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
// same link produce one grant and one failure rather than two.
//
// What is granted depends on what the invite was to: a project membership, a share
// on a single note, or — when it carries neither — nothing beyond the account the
// caller has just created.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return ErrInviteInvalid
}
var projectID sql.NullString
var projectID, noteID sql.NullString
var role Role
var invitedBy string
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &noteID, &role, &invitedBy); err != nil {
return err
}
if noteID.Valid && noteID.String != "" {
// created_by is the person who invited, not the person arriving: the row
// records who did the sharing, the same as a share made from the panel.
//
// The note may have been deleted between the invite and the signup. The
// foreign key would refuse the row and take the whole signup with it, so
// the note is looked up first and a share on a note that is gone is
// simply not made — the account is still worth creating.
var alive int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
noteID.String, userID).Scan(&alive); err != nil {
return err
}
if alive == 0 {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
@@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
})
}
// --- invitationer til én note ----------------------------------------------------
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
// The email address is the whole of who they are — there is no account to name
// them by, which is the entire reason the row exists.
type NoteInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListNoteInvites is who has been invited to a note and not yet taken it up.
//
// The panel needs them beside the people who *are* on the note: without this, an
// invitation sent yesterday is invisible, and the owner sends it again — a second
// link to the same inbox, and no way to take either back.
//
// Expired ones are left out, the same rule the administrator's list follows: an
// invite past its date is not something anybody can act on.
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, noteID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []NoteInvite{}
for rows.Next() {
var i NoteInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
// anything else.
//
// The note id is part of the WHERE rather than checked beforehand: the owner has
// been established for *this* note, and one statement that can only touch this
// note's rows cannot be talked into deleting a project invite by id.
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
return n > 0, err
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
+116
View File
@@ -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])
}
+54
View File
@@ -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)
}
}
@@ -0,0 +1,30 @@
-- En invitation kan handle om en note.
--
-- En note kunne deles med nogen, der allerede havde en konto — og kun med dem.
-- Skulle en, der ikke havde en, læse med, skulle man først invitere dem til
-- instansen, vente på at de oprettede sig, og så finde noten frem og dele den. Tre
-- skridt og en ventetid for det, der er én sætning: "lad Sofie se den her".
--
-- Rækken er den, der allerede findes. En invitation bærer i forvejen en e-mail, en
-- rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad
-- den er en invitation *til*. project_id sagde det for projekter, og note_id siger
-- det nu for noter.
--
-- To søjler frem for én kolonne med et id og en art ved siden af — samme valg som
-- notifications traf i 0033, og af samme grund: fremmednøglen. ON DELETE CASCADE
-- er dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage
-- imod en invitation til noget, der ikke er der længere. Et generisk par kan ikke
-- have den nøgle, og oprydningen skulle så skrives i kode, der huskede at køre.
--
-- De to udelukker hinanden i praksis, men det står ikke som en CHECK: en
-- invitation uden nogen af delene er den gyldige "kom med på instansen", og en
-- regel, der skulle tillade nul eller én af to, ville skulle skrives om hver gang
-- der kom en tredje slags ting at invitere til.
ALTER TABLE invites ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Panelet under en note spørger "hvem er inviteret til den her, og har ikke taget
-- imod endnu" hver gang, det åbnes. Det er opslaget, og det er derfor, det er
-- indekset. Kun de uafklarede: en invitation, der er taget imod, er en deling nu
-- og står i note_shares.
CREATE INDEX invites_pending_by_note
ON invites (note_id) WHERE accepted_at IS NULL AND note_id IS NOT NULL;
+6 -1
View File
@@ -75,7 +75,12 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
ORDER BY n.created_at DESC
-- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver
-- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på
-- afgør basen selv, hvilken der står øverst, og klokken viser dem i den
-- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er
-- den rigtige afgørelse at give den — og den er den samme hver gang.
ORDER BY n.created_at DESC, n.id DESC
LIMIT ?`, userID, limit)
if err != nil {
return nil, err
+5
View File
@@ -454,7 +454,12 @@ export const api = {
// path. Owner only; the list call also returns who it can still be shared with.
noteShares: (id) => get(`/notes/${id}/shares`),
shareNote: (id, userId, role) => post(`/notes/${id}/shares`, { user_id: userId, role }),
// Deling ved adresse. Har adressen en konto, bliver det en almindelig deling —
// svaret siger hvem — og ellers en invitation, der venter på, at kontoen bliver
// oprettet.
shareNoteByEmail: (id, email, role) => post(`/notes/${id}/shares`, { email, role }),
unshareNote: (id, userId) => del(`/notes/${id}/shares/${userId}`),
revokeNoteInvite: (id, inviteId) => del(`/notes/${id}/invites/${inviteId}`),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
+38 -2
View File
@@ -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'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else if item.kind === 'person'}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@@ -23,6 +23,8 @@
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
comment: 'notif.comment'
};
+17
View File
@@ -30,6 +30,8 @@ export const da = {
'notif.markAllRead': 'Markér alle som læst',
'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',
@@ -50,6 +52,21 @@ export const da = {
'notes.shareFollows': 'Følger med:',
'notes.shareVia': 'følger med “{note}”',
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
// Deling med en adresse. Ordene skal kunne bære begge udfald — en deling nu
// eller en invitation, der venter — uden at love det ene.
'notes.shareByEmail': 'Del med en e-mailadresse',
'notes.shareEmailPlaceholder': 'navn@eksempel.dk',
'notes.shareRole': 'Rolle',
'notes.shareInvite': 'Inviter',
'notes.invited': 'Invitationen er sendt til {email}.',
'notes.invitePending': 'inviteret',
'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',
+17
View File
@@ -27,6 +27,8 @@ export const en = {
'notif.markAllRead': 'Mark all as read',
'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',
@@ -47,6 +49,21 @@ export const en = {
'notes.shareFollows': 'Comes along:',
'notes.shareVia': 'came along with “{note}”',
'notes.unsharedPerson': '{name} can no longer see the note.',
// Sharing with an address. The words have to carry both outcomes — a share now,
// or an invitation that waits — without promising either one.
'notes.shareByEmail': 'Share with an email address',
'notes.shareEmailPlaceholder': 'name@example.com',
'notes.shareRole': 'Role',
'notes.shareInvite': 'Invite',
'notes.invited': 'The invitation has been sent to {email}.',
'notes.invitePending': 'invited',
'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',
+220
View File
@@ -199,8 +199,31 @@
let sharePick = $state('');
let shareRole = $state('viewer');
let shareBusy = $state(false);
// De inviterede: delinger, der venter på, at der bliver oprettet en konto.
// De står i panelet ved siden af dem, der er kommet — ellers er en invitation
// sendt i går usynlig, og så sender man den igen.
let shareInvites = $state([]);
let shareEmail = $state('');
// Linket vises kun, når instansen ikke kan sende post. Så er det den eneste vej
// frem, og det skal kunne kopieres frem for at forsvinde.
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.
@@ -228,6 +251,9 @@
shares = [];
shareCandidates = [];
shareFollows = [];
shareInvites = [];
shareEmail = '';
shareInviteLink = '';
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
@@ -238,6 +264,7 @@
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
})
.catch(() => {});
return () => {
@@ -274,6 +301,54 @@
}
}
/**
* Deling med en adresse frem for et navn i listen.
*
* Ét felt til begge udfald. Man skriver adressen på den, der skal læse med, og
* det er serveren, der ved, om der er en konto bag: er der det, bliver det en
* deling med det samme, og ellers en invitation, der bliver til en deling, når
* kontoen oprettes. At skulle vide det på forhånd — og vælge det rigtige felt —
* er at skulle vide noget om instansens brugerliste for at dele en note.
*/
async function inviteByEmail() {
if (!selected || !shareEmail.trim() || shareBusy) return;
shareBusy = true;
shareInviteLink = '';
try {
const r = await api.shareNoteByEmail(selected.id, shareEmail.trim(), shareRole);
if (r?.invited) {
shareInvites = [...shareInvites, r.invited];
// Kan instansen ikke sende post, er linket det eneste, der findes:
// vist frem for slugt, så det kan gives videre ad anden vej.
if (!r.emailed && r.link) shareInviteLink = r.link;
app.toast(t('notes.invited', { email: r.invited.email }));
} else if (r?.user) {
shares = [...shares, { user: r.user, role: r.role ?? shareRole }].sort((a, b) =>
byName(a.user, b.user)
);
shareCandidates = shareCandidates.filter((p) => p.id !== r.user.id);
shareFollows = r?.follows ?? shareFollows;
app.toast(t('notes.sharedToast', { name: r.user.name ?? '' }));
}
shareEmail = '';
} catch (e) {
app.toast(humanMessage(e));
} finally {
shareBusy = false;
}
}
async function revokeInvite(inviteId, email) {
try {
await api.revokeNoteInvite(selected.id, inviteId);
shareInvites = shareInvites.filter((i) => i.id !== inviteId);
shareInviteLink = '';
app.toast(t('notes.inviteRevoked', { email }));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function changeShareRole(userId, role) {
try {
await api.shareNote(selected.id, userId, role);
@@ -599,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 {
@@ -606,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
@@ -968,6 +1077,7 @@
<NoteEditor
note={selected}
notes={notes}
people={mentionable}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;
@@ -1083,6 +1193,25 @@
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
</p>
{/if}
{#if shareInvites.length}
<!-- Sendt, ikke taget imod endnu. Adressen er alt, hvad der findes
om personen indtil de dukker op — der er hverken navn eller
farve at vise, og det er selve pointen med linjen. -->
<ul class="invited">
{#each shareInvites as inv (inv.id)}
<li>
<span class="who">{inv.email}</span>
<span class="pending">{t('notes.invitePending')}</span>
<button
class="unshare"
onclick={() => revokeInvite(inv.id, inv.email)}
title={t('notes.inviteRevoke')}
aria-label={t('notes.inviteRevoke')}>×</button
>
</li>
{/each}
</ul>
{/if}
{#if shareCandidates.length}
<div class="addshare">
<select bind:value={sharePick}>
@@ -1104,6 +1233,40 @@
</button>
</div>
{/if}
<!-- Og den, der ikke står i listen. Ét felt til begge udfald: har
adressen en konto, bliver det en deling med det samme, og ellers
en invitation. Hvem der har en konto, er ikke noget, man skal
vide for at dele en note. -->
<form
class="addshare byemail"
onsubmit={(e) => {
e.preventDefault();
inviteByEmail();
}}
>
<input
bind:value={shareEmail}
type="email"
placeholder={t('notes.shareEmailPlaceholder')}
aria-label={t('notes.shareByEmail')}
/>
<select bind:value={shareRole} aria-label={t('notes.shareRole')}>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button class="button" type="submit" disabled={!shareEmail || shareBusy}>
{t('notes.shareInvite')}
</button>
</form>
{#if shareInviteLink}
<!-- Ingen post at sende det med. Så er linket det eneste, der
findes, og det skal kunne læses og kopieres frem for at blive
lovet i en besked, der ikke kom af sted. -->
<p class="invitelink">
{t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
</p>
{/if}
</section>
</div>
{/if}
@@ -1982,6 +2145,63 @@
text-decoration: underline;
}
/* De inviterede. Samme form som listen ovenover, fordi det er den samme slags
linje — en person, noten er delt med — og forskellen er, at de ikke er kommet
endnu. Den forskel siges med ordet til højre, ikke med en anden form. */
.invited {
list-style: none;
margin: 2px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.invited li {
display: flex;
align-items: center;
gap: var(--s1);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.invited .who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invited .pending {
margin-left: auto;
color: var(--ink-faint);
white-space: nowrap;
}
/* Adressefeltet får sin egen række af samme grund som personvælgeren: en
e-mailadresse klemt ned ved siden af en rolle og en knap bliver et felt, man
ikke kan læse det, man har skrevet i. */
.byemail input {
flex: 1 1 100%;
min-width: 0;
font-size: var(--text-xs);
padding: 2px var(--s1);
}
.invitelink {
margin: var(--s1) 0 0;
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.invitelink input {
width: 100%;
margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1);
}
.addshare {
display: flex;
align-items: center;