Compare commits

...
Author SHA1 Message Date
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
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:40:43 +02:00
Kristian Wind 48509e3073 Merge pull request #61 from kristianwind/delte-noter-tager-links-med
Release / Build and publish image (push) Failing after 8s
feat(noter): en delt note tager de noter med, den peger på
2026-09-07 22:59:47 +02:00
kristianwindandClaude Opus 5 916a0c0224 feat(noter): en delt note tager de noter med, den peger på
Den anden halvdel af den melding, der begyndte det her: linkene i en delt note
førte ingen steder hen, fordi det, de pegede på, ikke var delt. At dele en note
er at dele det, den *siger*, og et link er en del af det.

note_shares får en `linked`-søjle. 0 er en deling, nogen har valgt; 1 er en, der
er udledt af et link. Skellet er hele modellen. Fladen kan sige *hvorfor* en
person har en note — "fulgte med" er en anden sætning end "du delte den" — og de
udledte rækker kan regnes forfra efter hver ændring, uden at røre dem, ejeren
selv har sat.

SyncLinkedShares regner billedet om fra bunden efter enhver af ejerens egne
handlinger: en deling, en fjernet deling, en gemning der har ændret links eller
titel, en sletning. Hele kæden følger med, ikke kun første led, med et loft på
hundrede noter — "del denne ene note" må ikke kunne blive til fire hundrede.
Rollen arves fra den note, man kom fra, og stærkest vinder, når en note kan nås
ad to veje.

Kun ejerens egne noter følger med, og det er ikke en optimering: et link peger
på en titel, og en medredaktør må skrive titler. Kunne et link hente hvad som
helst, kunne den, man har delt med, skrive [[Bankoplysninger]] og læse med.
Derfor slås der kun op blandt ejerens egne noter, og derfor regner kun ejerens
egne gemninger om. Begge dele har en Go-prøve.

Det lukker ikke hullet helt: skriver medredaktøren linket, og gemmer ejeren
bagefter sin egen note, følger den note med dér. Det, der står imod, er
synlighed — linket står som tekst i ejerens egen note, og panelet viser hver
note, der er fulgt med, og hvem der har den.

Udregningen åbner hver af ejerens noter, fordi titlerne er forseglet. Derfor to
værn: har man ikke delt noget, svares der med det samme ud fra ét EXISTS, og en
gemning regner kun om, når links eller titel faktisk har flyttet sig. Uden det
første kostede "Ny note" halvfems millisekunder, og i det hul nåede e2e-prøven
for monospace at taste sin titel ind i den forrige note, som editoren så tegnede
væk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r4DxpUZcSShxfCZZaeMGZ
2026-09-07 22:28:58 +02:00
Kristian Wind ff75302cf8 Merge pull request #60 from kristianwind/note-links-og-kodeknap
fix(noter): følg note-links i en delt note, og lad </> være kode
2026-09-07 21:58:29 +02:00
24 changed files with 2333 additions and 93 deletions
+118 -3
View File
@@ -3126,25 +3126,103 @@ 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
alongside it. Owner's own notes only.
type: array
items: { $ref: "#/components/schemas/NoteFollow" }
"404": { $ref: "#/components/responses/NotFound" }
post:
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.
requestBody:
required: true
content:
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:
"204": { description: Shared }
"200":
description: Shared
content:
application/json:
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]
@@ -4035,6 +4113,33 @@ components:
properties:
user: { $ref: "#/components/schemas/Person" }
role: { type: string, enum: [viewer, editor] }
linked:
description: >
True when the person has this note because another shared note points
at it, rather than because it was shared with them on its own. Such a
grant is taken back by removing the link or the share it came from, not
by removing it here.
type: boolean
via:
description: The title of the shared note that points at this one.
type: string
NoteFollow:
type: object
description: A note that is shared alongside another because that one links to it.
properties:
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
@@ -4524,12 +4629,22 @@ components:
Notification:
type: object
description: >
Something somebody else did that you need to know about. `kind` says what
happened and the client composes the sentence from it, so the bell follows
the reader's language; `title` is the server's own Danish wording, used for
Web Push, which has no dictionary to look words up in.
properties:
id: { type: string }
actor_name: { type: string }
project_id: { type: string }
task_id: { type: string }
kind: { type: string }
note_id:
description: Set on a notification about a note — a shared note somebody else edited.
type: string
kind:
type: string
enum: [assigned, note.changed, comment]
title: { type: string }
body: { type: string }
read: { type: boolean }
+78
View File
@@ -204,9 +204,49 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "create note", err)
return
}
// En ny note kan være den, en delt note hele tiden har peget på: linket er
// skrevet som en titel, og titlen fandtes ikke før nu.
//
// Kun når der står noget i den. En tom note har ingen titel, og et link peger på
// en titel — så der er intet at regne, og det kostede alligevel: udregningen
// åbner hver eneste af ejerens noter, og den lå her mellem "Ny note" og det
// svar, fladen venter på for at kunne tegne editoren. Halvfems millisekunder er
// nok til at nå at taste den første linje ind i den forrige note, som så blev
// 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)
}
writeJSON(w, http.StatusCreated, n)
}
// followLinks holder de udledte delinger i takt med teksten.
//
// De noter, en delt note peger på, følger med — og teksten kan ændre sig, efter
// delingen er sat. Et link skrevet i dag i en note, der blev delt i går, skal føre
// samme sted hen for begge.
//
// Kun ejerens egne gemninger regner om, og det er ikke en optimering. Et link
// peger på en titel, og en medredaktør må skrive i teksten — så hvis enhver
// gemning talte med, kunne den, man har delt en note med, skrive
// `[[Bankoplysninger]]` ind i den og dermed dele den med sig selv.
//
// Det lukker ikke hullet helt, og det skal siges frem for at lyde lukket: skriver
// medredaktøren linket, og gemmer ejeren bagefter sin egen note, følger den note
// med dér. Det, der står tilbage mod det, er synlighed — linket står som tekst i
// ejerens egen note, og delingspanelet viser hver note, der er fulgt med, og hvem
// der har den. En stille eskalering er det ikke; en, man kan overse, er den.
//
// Fejler det, siges det i loggen og ikke til den, der gemte: noten *er* gemt, og
// et svar, der lyder som om den ikke er, ville få dem til at skrive den igen.
func (s *Server) followLinks(r *http.Request, n *store.Note) {
if n == nil || n.CreatedBy != userFrom(r.Context()).ID {
return
}
if _, err := s.db.SyncLinkedShares(r.Context(), n.CreatedBy); err != nil {
s.log.Error("sync linked shares", "err", err, "note", n.ID)
}
}
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body noteBody
if err := decodeJSON(w, r, &body); err != nil {
@@ -242,6 +282,7 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
}
n.ProjectID = *body.ProjectID
}
before := n.Body
if body.Body != nil {
n.Body = *body.Body
}
@@ -249,13 +290,47 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
n.Pinned = *body.Pinned
}
// Læst før gemningen, sammenlignet efter. En gemning er en tastepause, ikke en
// begivenhed, så de fleste af dem ændrer intet af det her — og udregningen
// åbner hver eneste af ejerens noter, fordi titlerne er forseglet. Den skal
// køre, når svaret kan være blevet et andet, og ellers ikke.
wasLinking := store.LinksIn(before)
wasTitled := n.Title
if err := s.db.SaveNote(r.Context(), n); err != nil {
s.internal(w, r, "update note", err)
return
}
// Titlen tæller med: den *er* første linje, så en omskrevet overskrift flytter
// det, andres links peger på, uden at et eneste link er rørt.
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
s.followLinks(r, n)
}
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
// det ville lære folk at overse dem alle sammen.
if before != n.Body {
s.notifyNoteChanged(r, n)
}
writeJSON(w, http.StatusOK, n)
}
func sameLinks(a, b []store.NoteLink) bool {
if len(a) != len(b) {
return false
}
in := make(map[store.NoteLink]bool, len(a))
for _, l := range a {
in[l] = true
}
for _, l := range b {
if !in[l] {
return false
}
}
return true
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
@@ -279,6 +354,9 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "delete note", err)
return
}
// En note i papirkurven er ikke længere et udgangspunkt for noget. Det, den tog
// med sig, skal derfor heller ikke blive stående hos dem, den blev delt med.
s.followLinks(r, n)
w.WriteHeader(http.StatusNoContent)
}
+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")
}
}
+215 -8
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
@@ -77,12 +78,37 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list note shares", err)
return
}
// Regnet forfra, én gang, og læst to veje ud af det samme svar: hvem der har
// *denne* note, fordi en anden peger på den, og hvilke noter der omvendt følger
// med den her. To udregninger af det samme er to, der kan blive uenige.
//
// Og forfra frem for læst af basen, så panelet viser det, der gælder nu — også
// når linkene er skrevet af en anden og delingerne derfor ikke er regnet om
// siden.
following, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
via := map[string]string{}
for _, l := range following {
if l.NoteID == n.ID {
via[l.UserID] = l.Via
}
}
out := make([]noteShareJSON, 0, len(shares))
already := map[string]bool{}
for _, sh := range shares {
// Panelet skal kunne sige forskel: en deling, nogen har valgt, tages tilbage
// ved at fjerne den — en, der er fulgt med, ved at fjerne linket eller den
// deling, den kom fra. En knap, der lover det første og gør det andet, er
// værre end ingen knap.
out = append(out, noteShareJSON{
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
User: personJSON{ID: sh.User.ID, Name: sh.User.Name, AvatarColor: sh.User.AvatarColor},
Role: string(sh.Role),
Linked: via[sh.User.ID] != "",
Via: via[sh.User.ID],
})
already[sh.User.ID] = true
}
@@ -103,20 +129,86 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"shares": out, "candidates": cand})
// 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),
})
}
// followsOf er de noter, der følger med *denne* note — én linje pr. note, ikke én
// pr. note og person. Panelet spørger på vegne af noten, og svaret "Prisliste
// følger med" er det samme, hvem det så end deles med.
func followsOf(all []store.LinkedShare, noteID string) []followJSON {
seen := map[string]bool{}
out := []followJSON{}
for _, l := range all {
if l.ViaID != noteID || seen[l.NoteID] {
continue
}
seen[l.NoteID] = true
out = append(out, followJSON{NoteID: l.NoteID, Title: l.Title})
}
return out
}
type followJSON struct {
NoteID string `json:"note_id"`
Title string `json:"title"`
}
type noteShareJSON struct {
User personJSON `json:"user"`
Role string `json:"role"`
// Sat, når personen har noten, fordi en anden delt note peger på den — ikke
// fordi nogen har delt netop den. `via` er den note, der peger.
Linked bool `json:"linked"`
Via string `json:"via,omitempty"`
}
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 {
@@ -140,25 +232,133 @@ 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(), 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),
// 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)
}
@@ -177,5 +377,12 @@ func (s *Server) handleUnshareNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "unshare note", err)
return
}
// Det, der fulgte med, følger også med tilbage. Regnet forfra: de udledte rækker
// skrives om fra bunden, så en note, der stadig kan nås fra en anden deling,
// bliver stående, og en, der ikke kan, forsvinder.
if _, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID); err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+100 -3
View File
@@ -46,7 +46,7 @@ func TestSharedNoteReachesTheOtherPerson(t *testing.T) {
// Share it as a viewer.
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusNoContent {
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
@@ -92,7 +92,7 @@ func TestSharedEditorCanWriteButNotGiveAway(t *testing.T) {
noteID := owner.createNote(t, "# Delt\noprindelig")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusNoContent {
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share as editor failed")
}
@@ -193,7 +193,104 @@ func TestShareRejectsUnknownAndSelf(t *testing.T) {
t.Errorf("sharing with oneself: %d, want 422", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusNoContent {
map[string]string{"user_id": me, "role": "owner"}); resp.StatusCode == http.StatusOK {
t.Error("a note was shared at the owner role")
}
}
// De noter, en delt note peger på, følger med — og holder op igen.
//
// Det var meldt som en fejl den anden vej rundt: linkene i en delt note førte
// ingen steder hen, fordi det, de pegede på, ikke var delt. Prøven går hele vejen
// rundt om svaret på det — dybden, teksten der ændrer sig bagefter, det der ikke
// må følge med, og vejen tilbage.
func TestSharedNoteTakesItsLinksWithIt(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
// Prisliste peger videre på Leveringstider, så kæden er to led lang.
deep := owner.createNote(t, "# Leveringstider\nfire dage")
linked := owner.createNote(t, "# Prisliste\nog [[Leveringstider]]")
apart := owner.createNote(t, "# Bankoplysninger\nikke til deling")
noteID := owner.createNote(t, "# Aftale om levering\nse [[Prisliste]]")
reachable := func(id string) bool {
resp, _ := other.do(t, "GET", "/api/v1/notes/"+id, nil)
return resp.StatusCode == http.StatusOK
}
resp, out := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "viewer"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
// Svaret siger, hvad der fulgte med — hele kæden, ikke kun første led. En
// deling, der stille tog to noter mere med, ville være rigtig og alligevel ikke
// til at overskue.
follows, _ := out["follows"].([]any)
if len(follows) != 2 {
t.Fatalf("delingen nævnte %d noter, der fulgte med, want 2: %v", len(follows), out["follows"])
}
if !reachable(linked) {
t.Error("noten, den delte note peger på, fulgte ikke med")
}
if !reachable(deep) {
t.Error("kæden stoppede efter ét led")
}
if reachable(apart) {
t.Error("en note, ingen peger på, blev delt")
}
// Skrevet bagefter tæller også. Et link i dag i en note delt i går skal føre
// samme sted hen for begge.
senere := owner.createNote(t, "# Rabatter\nti procent")
if reachable(senere) {
t.Fatal("en helt ny note var delt, før noget pegede på den")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nse [[Prisliste]] og [[Rabatter]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("kunne ikke skrive videre i den delte note")
}
if !reachable(senere) {
t.Error("et link skrevet efter delingen fulgte ikke med")
}
// Og vejen tilbage: tages delingen tilbage, følger det med, den tog med sig.
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/shares/"+sofie, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("unshare: status %d", resp.StatusCode)
}
if reachable(linked) || reachable(deep) || reachable(senere) {
t.Error("det, der fulgte med delingen, blev stående, da den blev taget tilbage")
}
}
// En medredaktør kan ikke dele ejerens noter med sig selv ved at skrive et link.
//
// Det er den ene vej, "linkene følger med" kunne blive til en rettighedsfejl: et
// link peger på en titel, og enhver, der må rette i en delt note, kan skrive en
// titel. Derfor regnes delingerne kun om, når ejeren selv har gemt.
func TestAnEditorCannotShareTheOwnersNotesWithThemselves(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
secret := owner.createNote(t, "# Bankoplysninger\nreg og konto")
noteID := owner.createNote(t, "# Fælles\nintet link her")
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("share as editor failed")
}
// Hun skriver linket selv, i den note hun har fået lov at rette i.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Fælles\nse [[Bankoplysninger]]"}); resp.StatusCode != http.StatusOK {
t.Fatal("medredaktøren kunne ikke skrive")
}
if resp, _ := other.do(t, "GET", "/api/v1/notes/"+secret, nil); resp.StatusCode == http.StatusOK {
t.Error("en medredaktør delte ejerens note med sig selv ved at skrive et link")
}
}
+86 -4
View File
@@ -19,6 +19,7 @@ type notificationJSON struct {
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
NoteID string `json:"note_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
@@ -102,6 +104,86 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// notifyAssigned tells somebody a task is now theirs.
//
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
// hvad de lige har gjort.
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
if t == nil || t.AssigneeID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
})
}
// 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.
//
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
if n == nil {
return
}
actor := userFrom(r.Context())
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
return
}
title := actor.Name + " rettede en note"
body := n.Title
for _, userID := range audience {
if userID == actor.ID {
continue
}
// Fold first, and only write a row when there was nothing to fold into.
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
// hvor klokken siger to.
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
if err != nil {
s.log.Warn("fold note notification", "err", err, "note", n.ID)
continue
}
if folded {
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
// op og siger nu et andet navn. Uden det her opdager en åben fane først
// den sammenlagte rettelse ved næste genindlæsning.
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.changed", Title: title, Body: body,
})
}
}
// PushToUser is pushToUser for callers outside this package — the reminder job,
// which has the schedule but not the keys.
func (s *Server) PushToUser(userID, title, body, projectID string) {
+205
View File
@@ -0,0 +1,205 @@
package httpapi
import (
"net/http"
"testing"
)
// notifications reads somebody's bell.
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list notifications: status %d", resp.StatusCode)
}
list, _ := out["notifications"].([]any)
unread, _ := out["unread"].(float64)
return list, unread
}
func firstOf(t *testing.T, list []any) map[string]any {
t.Helper()
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
n, _ := list[0].(map[string]any)
return n
}
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
projectID, _ := project["id"].(string)
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
// medlem med det samme.
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
t.Fatalf("invitér: %d", resp.StatusCode)
}
resp, task := owner.do(t, "POST", "/api/v1/tasks",
map[string]any{"project_id": projectID, "content": "ring til revisor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("opret opgave: %d", resp.StatusCode)
}
taskID, _ := task["id"].(string)
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
before, _ := notifications(t, other)
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel: %d", resp.StatusCode)
}
list, _ := notifications(t, other)
if len(list) != len(before)+1 {
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
}
n := firstOf(t, list)
if n["kind"] != "assigned" {
t.Errorf("kind = %v, want assigned", n["kind"])
}
if n["task_id"] != taskID {
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
}
if n["actor_name"] != "Kristian" {
t.Errorf("actor_name = %v", n["actor_name"])
}
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
// ændring, gør — og så bliver klokken ubrugelig på en dag.
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret: %d", resp.StatusCode)
}
if now, _ := notifications(t, other); len(now) != len(before)+1 {
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
}
// Og den, der tildeler sig selv, får ingenting.
me := userID(t, owner, "kristian@example.dk")
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
}
}
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
// så længe den ikke er læst.
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
// 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 != 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 {
t.Errorf("besked = %v", n)
}
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
// ens linjer i klokken.
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten igen")
}
}
if list, unread := notifications(t, other); len(list) != 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.
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
t.Fatal("markér som læst")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten efter læsningen")
}
if list, unread := notifications(t, other); len(list) != 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.
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
}
// Og den anden vej: Sofie skriver, ejeren hører det.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
t.Fatal("Sofie retter")
}
list, _ = notifications(t, owner)
if len(list) != 1 {
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
}
if firstOf(t, list)["actor_name"] != "Sofie" {
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
}
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
t.Fatal("stjernemarkér")
}
if list, _ := notifications(t, other); len(list) != 3 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
}
}
// En note, ingen deler, er ingen at give besked.
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
noteID := owner.createNote(t, "# Min egen\nintet")
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
}
}
+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)
+18
View File
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
t.Labels = req.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
// ind i "det her er dit nu" giver besked og den anden ikke.
s.notifyAssigned(r, t)
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
}
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
// det hver gang nogen retter en overskrift.
wasAssigned := ""
if req.AssigneeID != nil {
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
wasAssigned = before.AssigneeID
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
s.notifyAssigned(r, t)
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
+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
@@ -0,0 +1,22 @@
-- De noter, en delt note peger på, følger med delingen.
--
-- Et `[[link]]` i en note, der er delt, pegede indtil nu på noget, modtageren
-- ikke havde fået. Linket stod der, kunne ses, og førte ingen steder hen — meldt
-- af den første, der prøvede at læse en delt note, og med rette: den, der deler
-- en note, deler det, noten *siger*, og et link er en del af det, den siger.
--
-- Rækken er den samme som en almindelig deling. `linked` er den ene forskel: den
-- siger, at rækken ikke er valgt af nogen, men udledt af, hvad noten peger på.
-- Det gør to ting muligt, som en boolsk værdi ellers sjældent gør sig fortjent
-- til. Fladen kan sige *hvorfor* en person har den note — "den fulgte med" er en
-- anden sætning end "du delte den". Og udledningen kan regnes forfra hver gang:
-- de udledte rækker må slettes og skrives igen efter hver ændring, mens de
-- valgte aldrig røres. Uden skellet ville en oprydning enten lade forældede
-- delinger blive stående eller fjerne dem, ejeren selv havde sat.
--
-- Kun ejerens egne noter følger med. Et link peger på en *titel*, og titler kan
-- skrives af enhver, der må rette i noten — så hvis et link kunne hente hvad som
-- helst, ville en medredaktør kunne skrive `[[Bankoplysninger]]` og læse med.
-- Opslaget sker derfor kun blandt de noter, ejeren selv ejer, og deling af dem
-- har ejeren allerede ret til.
ALTER TABLE note_shares ADD COLUMN linked INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,18 @@
-- En besked kan handle om en note.
--
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
-- for at opdage det.
--
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
-- — kan et generisk par slet ikke have.
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
-- er nøjagtig det: min, ulæst, om denne note.
CREATE INDEX notifications_unread_by_note
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
@@ -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;
+279
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"time"
)
@@ -131,3 +133,280 @@ func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []stri
}
return out, rows.Err()
}
// --- de noter, en delt note peger på ---------------------------------------------
// LinkedShare is one note that followed a share: which note, what it is called,
// and which of the owner's shared notes brought it. The panel shows all three —
// "Prisliste came along with Aftale om levering" is a sentence somebody can act
// on, where a bare id is not.
type LinkedShare struct {
NoteID string `json:"note_id"`
Title string `json:"title"`
ViaID string `json:"via_id"`
Via string `json:"via"`
Role Role `json:"role"`
UserID string `json:"user_id"`
}
// Hvor langt en deling må brede sig.
//
// En note, der peger på en note, der peger på en note, er stadig én ting at læse,
// og den skal følge med. Men noter kan pege i ring og i vifte, og "del denne ene
// note" må ikke kunne blive til fire hundrede. Loftet er ikke en optimering: det
// er den grænse, hvor en deling holder op med at være noget, ejeren kan overskue.
const linkedShareLimit = 100
// SyncLinkedShares regner de udledte delinger forfra for alt, hvad én person ejer.
//
// Kaldt efter enhver af ejerens egne handlinger, der kan flytte svaret: en deling,
// en fjernet deling, og en gemning, der har ændret på det, noten peger på. Regnet
// forfra frem for rettet til, fordi det er den samme kode hver gang og derfor det
// samme resultat hver gang — en tilføjelse og en fjernelse er ikke to veje gennem
// koden, men det samme kald med et andet udgangspunkt.
//
// Kun *direkte* delinger er udgangspunkt. En note i et delt projekt er delt af
// projektet, og projektets folk kan se projektets noter; det er en anden vej ind,
// med sine egne regler, og den blander sig ikke her.
//
// Returnerer, hvad der nu følger med, så kalderen kan sige det. Det er meningen at
// det bliver sagt: en deling, der stille tager tre noter mere med, er ikke til at
// overskue, uanset hvor rigtigt den gør det.
func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedShare, error) {
if ownerID == "" {
return nil, nil
}
// Har personen ikke delt noget, er der intet at regne — og det er de fleste,
// det meste af tiden. Uden det her ville hvert eneste klik i notelisten åbne
// hver eneste note for at finde ud af, at svaret er tomt: fladen spørger til
// delingerne, hver gang man vælger en note, man selv ejer.
//
// Ét spørgsmål dækker begge halvdele. Er der ingen rækker overhovedet, er der
// hverken en deling at brede ud eller en udledt række at rydde op efter.
var anyShare int
if err := db.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM note_shares s
JOIN notes n ON n.id = s.note_id
WHERE n.created_by = ?)`, ownerID).Scan(&anyShare); err != nil {
return nil, err
}
if anyShare == 0 {
return nil, nil
}
// Ejerens egne noter, åbnet. Titlerne er forseglet i basen, og et link peger på
// en titel — så der er ikke noget opslag at lave i SQL. Målt andetsteds i denne
// fil: tolv hundrede noter koster nogle og tredive millisekunder at åbne, og det
// her kører kun, når ejeren selv har gjort noget.
mine, err := db.notesWhere(ctx, `WHERE created_by = ? AND deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
byTitle := map[string]string{}
title := map[string]string{}
for _, n := range mine {
title[n.ID] = n.Title
key := strings.ToLower(strings.TrimSpace(n.Title))
// Den første vinder, og listen kommer sorteret med den senest rørte først:
// to noter med samme titel er sjældent, og når det sker, er den, man skrev
// sidst, den, man mente.
if key != "" {
if _, taken := byTitle[key]; !taken {
byTitle[key] = n.ID
}
}
}
// Hvad hver note peger på, i ét spørgsmål frem for ét pr. note.
links := map[string][]string{}
rows, err := db.QueryContext(ctx, `
SELECT l.note_id, l.target_id
FROM note_links l
JOIN notes n ON n.id = l.note_id
WHERE l.kind = 'note' AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
for rows.Next() {
var from, target string
if err := rows.Scan(&from, &target); err != nil {
rows.Close()
return nil, err
}
links[from] = append(links[from], target)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// De delinger, ejeren selv har sat.
type grant struct{ noteID, userID string }
direct := map[grant]Role{}
rows, err = db.QueryContext(ctx, `
SELECT s.note_id, s.user_id, s.role
FROM note_shares s
JOIN notes n ON n.id = s.note_id
WHERE s.linked = 0 AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
if err != nil {
return nil, err
}
for rows.Next() {
var g grant
var role Role
if err := rows.Scan(&g.noteID, &g.userID, &role); err != nil {
rows.Close()
return nil, err
}
if role.Valid() {
direct[g] = role
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Vandringen. Pr. person, fordi det er personen, delingen handler om: to
// forskellige mennesker kan have to forskellige noter og derfor to forskellige
// vifter ud fra dem.
wanted := map[grant]LinkedShare{}
perUser := map[string][]grant{}
for g := range direct {
perUser[g.userID] = append(perUser[g.userID], g)
}
for userID, roots := range perUser {
seen := map[string]bool{}
type step struct {
noteID string
via string
}
// Medredaktør først, læser bagefter, og hver runde tømt for sig.
//
// En note kan nås fra to delte noter med hver sin rolle, og så skal den
// stærkeste gælde. Én kø med det hele i ville afgøre det på afstand frem for
// på rolle: et barnebarn af en redigerbar note ville tabe til et barn af en
// læsbar, fordi det stod længere bagude i køen. To runder gør reglen til
// rækkefølgen — er noten allerede nået som redigerbar, ser den anden runde
// den ikke.
for _, role := range []Role{RoleEditor, RoleViewer} {
var queue []step
for _, g := range roots {
if direct[g] != role || seen[g.noteID] {
continue
}
seen[g.noteID] = true
queue = append(queue, step{noteID: g.noteID, via: g.noteID})
}
for len(queue) > 0 && len(seen) <= linkedShareLimit {
cur := queue[0]
queue = queue[1:]
for _, target := range links[cur.noteID] {
id, ok := byTitle[strings.ToLower(strings.TrimSpace(target))]
// Et link, der ikke rammer en af ejerens egne noter, er enten
// dødt eller peger på en andens note. Ingen af delene er ejerens
// at dele.
if !ok || seen[id] {
continue
}
seen[id] = true
// Rollen arves fra den note, man kom fra. En medredaktør på en
// note bliver medredaktør på det, den peger på: teksten er én
// ting at arbejde i, og et link midt i den er ikke en grænse.
if _, isDirect := direct[grant{noteID: id, userID: userID}]; !isDirect {
wanted[grant{noteID: id, userID: userID}] = LinkedShare{
NoteID: id,
Title: title[id],
ViaID: cur.via,
Via: title[cur.via],
Role: role,
UserID: userID,
}
}
queue = append(queue, step{noteID: id, via: cur.via})
}
}
}
}
// Skrevet i én transaktion: mellem sletningen og skrivningen står basen med
// færre delinger, end den skal, og det er ikke et vindue nogen skal kunne læse i.
err = db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM note_shares
WHERE linked = 1
AND note_id IN (SELECT id FROM notes WHERE created_by = ?)`, ownerID); err != nil {
return err
}
for g, l := range wanted {
// DO NOTHING ved sammenstød: en direkte deling på den samme note er
// ejerens eget valg og har forrang. Den kan ikke nå herned — den er
// sorteret fra ovenfor — men reglen skal stå dér, hvor rækken skrives,
// ikke kun dér, hvor den udregnes.
if _, err := tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at, linked)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT (note_id, user_id) DO NOTHING`,
g.noteID, g.userID, l.Role, ownerID, time.Now().Unix()); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
out := make([]LinkedShare, 0, len(wanted))
for _, l := range wanted {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool {
if out[i].UserID != out[j].UserID {
return out[i].UserID < out[j].UserID
}
return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title)
})
return out, nil
}
// NoteAudience is everybody who can see a note: its owner, the people it is shared
// with — chosen or followed along a link — and the members of the project it is
// filed in.
//
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
// delt direkte, og to beskeder om den samme rettelse er én for mange.
//
// The note's own row is joined rather than passed in, so a caller cannot ask about
// a note that has been deleted and get an audience for it anyway.
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
UNION
SELECT user_id FROM note_shares WHERE note_id = ?
UNION
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
UNION
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL`,
noteID, noteID, noteID, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
+39 -8
View File
@@ -18,6 +18,7 @@ type Notification struct {
ActorName string
ProjectID string
TaskID string
NoteID string
Kind string
Title string
Body string
@@ -32,25 +33,54 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
n.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
title, body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
return err
}
// BumpUnreadNoteNotification folds a second change to the same note into the first,
// and says whether it found one to fold into.
//
// A note is saved on a typing pause, so writing a row per save would turn one
// person editing one note for five minutes into a column of twenty identical
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
// der rørte den sidst.
//
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
// gentagelse af noget, man allerede har set.
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
res, err := db.ExecContext(ctx, `
UPDATE notifications
SET created_at = ?, actor_id = ?, title = ?, body = ?
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
rows, err := db.QueryContext(ctx, `
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
n.kind, n.title, n.body, n.read_at, n.created_at
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
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
@@ -60,14 +90,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
out := []Notification{}
for rows.Next() {
var n Notification
var actorID, projectID, taskID sql.NullString
var actorID, projectID, taskID, noteID sql.NullString
var readAt sql.NullInt64
var created int64
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
&taskID, &noteID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
return nil, err
}
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
n.ActorID, n.ProjectID = actorID.String, projectID.String
n.TaskID, n.NoteID = taskID.String, noteID.String
if readAt.Valid {
v := time.Unix(readAt.Int64, 0).UTC()
n.ReadAt = &v
+111 -15
View File
@@ -2875,13 +2875,13 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Prisliste for foraaret');
await page.keyboard.type('Kristians prisblad');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Aftale om levering\n');
await page.keyboard.type('[[Prisliste for foraaret]]');
await page.keyboard.type('Faelles aftale om afhentning\n');
await page.keyboard.type('[[Kristians prisblad]]');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Gæsten har en note af sin egen, som hun kan pege på.
@@ -2891,7 +2891,7 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await gæst.keyboard.type('Linneas egne maal');
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
@@ -2901,19 +2901,19 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await expect(row).toBeVisible();
await row.locator('select').selectOption('editor');
// Hun skriver sit eget link ind i den delte note.
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
await gæst.goto(noterURL);
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(body).toContainText('Prisliste for foraaret');
await expect(body).toContainText('Kristians prisblad');
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gæst.goto(noterURL);
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
// Hendes eget link fører derhen, det siger.
@@ -2922,22 +2922,118 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
'Linneas egne maal'
);
// Ejerens link peger på en note, hun ikke har fået. Den må ikke lande et
// tilfældigt sted — og den må sige hvorfor.
await gæst.locator('.notes').getByRole('button', { name: /Aftale om levering/ }).click();
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
// allerede stod i — hvilket ser ud som ingenting.
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(igen).toContainText('Prisliste for foraaret');
await igen.locator('a.notelink', { hasText: 'Prisliste for foraaret' }).click();
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
// Stadig i den delte note: linket flyttede hende ingen steder hen.
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Aftale om levering'
'Faelles aftale om afhentning'
);
await gæstCtx.close();
expect(trouble).toEqual([]);
});
/**
* Delingen tager de noter med, noten peger på.
*
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
* det, den *siger*, og et link er en del af det.
*/
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('med@example.dk');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await ctx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
for (const text of [
'Leveringstider for pakker',
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
'Bankoplysninger som ingen skal se',
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
await expect(page.locator('.toast')).toContainText('de 2 noter');
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await gæst
.getByRole('textbox', { name: 'Notens tekst' })
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
.click();
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(hos).toContainText('Prisliste i kroner');
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Leveringstider for pakker'
);
// Men kun det, noten peger på. Resten er stadig ejerens alene.
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
).toHaveCount(0);
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
// knap med den samme titel på.
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
await page.locator('.sharewrap > button').click();
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
'følger med'
);
// Taget tilbage følger den med tilbage.
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
await page
.locator('.sharepanel .sharelist li')
.filter({ hasText: 'Mikkel' })
.getByRole('button', { name: 'Fjern adgang' })
.click();
await gæst.goto(noterURL);
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
).toHaveCount(0);
await ctx.close();
expect(trouble).toEqual([]);
});
/**
* `</>` er kode, ikke kilde.
*
+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.
+269
View File
@@ -0,0 +1,269 @@
<script>
/**
* Klokken.
*
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
* delt projekt, er en klokke, folk holder op med at se på.
*
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
*/
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { goto } from '$app/navigation';
let open = $state(false);
let wrap = $state(null);
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
comment: 'notif.comment'
};
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
function when(iso) {
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
if (minutes < 1) return t('notif.justNow');
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}
/**
* Hvor en besked fører hen.
*
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
* man går hen.
*/
function follow(n) {
open = false;
if (!n.read) app.markRead(n.id);
if (n.note_id) goto(`/noter?note=${n.note_id}`);
else if (n.task_id) app.openDetail(n.task_id);
else if (n.project_id) goto(`/projekt/${n.project_id}`);
}
function outside(event) {
if (open && wrap && !wrap.contains(event.target)) open = false;
}
</script>
<svelte:window
onclick={outside}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
/>
<div class="wrap" bind:this={wrap}>
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
? t('notif.titleWithCount', { n: String(app.unread) })
: t('notif.title')}
title={t('notif.title')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10.3 21a2 2 0 003.4 0" />
</svg>
{#if app.unread}
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
{/if}
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
{/if}
</header>
{#if app.notifications.length}
<ul>
{#each app.notifications as n (n.id)}
<li>
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
<span class="what">{headline(n)}</span>
{#if n.body}<span class="about">{n.body}</span>{/if}
<span class="ago">{when(n.created_at)}</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="none">{t('notif.empty')}</p>
{/if}
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
}
.bell {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: var(--radius);
color: var(--ink-muted);
cursor: pointer;
}
.bell:hover,
.bell.on {
background: var(--surface-raised);
color: var(--ink);
}
.bell svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.count {
position: absolute;
top: 1px;
right: 0;
min-width: 15px;
padding: 0 3px;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--on-accent, #fff);
font-size: 10px;
line-height: 15px;
font-weight: 600;
text-align: center;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 40;
width: min(340px, calc(100vw - var(--s3) * 2));
max-height: 60vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
.panel header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
padding: var(--s2);
border-bottom: 1px solid var(--line);
font-size: var(--text-xs);
color: var(--ink-muted);
}
.allread {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.allread:hover {
text-decoration: underline;
}
.panel ul {
list-style: none;
margin: 0;
padding: 0;
}
.row {
display: grid;
gap: 2px;
width: 100%;
padding: var(--s2);
border: none;
border-bottom: 1px solid var(--line);
background: none;
text-align: left;
cursor: pointer;
color: var(--ink-muted);
}
.row:hover {
background: var(--surface-raised);
}
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
kunne se hvad der var én besked. */
.row.unread .what {
font-weight: 600;
color: var(--ink);
}
.row.unread .what::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
vertical-align: middle;
border-radius: var(--radius-full);
background: var(--accent);
}
.what {
font-size: var(--text-sm);
}
.about {
font-size: var(--text-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ago {
font-size: var(--text-xs);
color: var(--ink-faint);
}
.none {
margin: 0;
padding: var(--s3) var(--s2);
text-align: center;
font-size: var(--text-xs);
color: var(--ink-faint);
}
</style>
+27
View File
@@ -22,6 +22,19 @@ export const da = {
'notes.unshared': 'Noten er din igen.',
// Deling med enkeltpersoner, ved siden af projektet.
'notes.groupSharedWithMe': 'Delt med mig',
// Klokken: hvad andre har gjort, som man skal vide. Sætningen skrives i fladen
// og ikke på serveren, så en engelsk flade også får en engelsk klokke.
'notif.title': 'Beskeder',
'notif.titleWithCount': 'Beskeder ({n} ulæste)',
'notif.empty': 'Ingen beskeder.',
'notif.markAllRead': 'Markér alle som læst',
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
'notif.hours': '{n} t. siden',
'notes.linkOpen': 'Åbn',
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
'notes.sharedBy': 'Delt af {name}',
@@ -34,7 +47,21 @@ export const da = {
'notes.shareRemove': 'Fjern adgang',
'notes.shareNobody': 'Ikke delt med nogen endnu.',
'notes.sharedToast': 'Delt med {name}.',
'notes.sharedWithFollowers': 'Delt med {name} — og de {count} noter, den peger på.',
'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:',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
+28
View File
@@ -18,6 +18,20 @@ export const en = {
'notes.unshared': 'The note is yours again.',
// Sharing with individual people, alongside the project.
'notes.groupSharedWithMe': 'Shared with me',
// The bell: what other people did that you need to know about. The sentence is
// composed here rather than on the server, so an English interface gets an
// English bell.
'notif.title': 'Notifications',
'notif.titleWithCount': 'Notifications ({n} unread)',
'notif.empty': 'Nothing yet.',
'notif.markAllRead': 'Mark all as read',
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
'notif.hours': '{n} h ago',
'notes.linkOpen': 'Open',
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
'notes.sharedBy': 'Shared by {name}',
@@ -30,7 +44,21 @@ export const en = {
'notes.shareRemove': 'Remove access',
'notes.shareNobody': 'Not shared with anyone yet.',
'notes.sharedToast': 'Shared with {name}.',
'notes.sharedWithFollowers': 'Shared with {name} — along with the {count} notes it points at.',
'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:',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
'notes.format': 'Format',
+55
View File
@@ -108,6 +108,7 @@ class AppState {
this.groups = groups;
this.people = people;
this.connect();
this.loadNotifications();
} catch (e) {
this.user = null;
} finally {
@@ -145,10 +146,64 @@ class AppState {
socket.onerror = () => socket.close();
}
// --- klokken -----------------------------------------------------------------
//
// Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje:
// hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En
// klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste —
// og en besked, man skal genindlæse for at se, er ikke en besked.
notifications = $state([]);
unread = $state(0);
async loadNotifications() {
try {
const r = await api.listNotifications();
this.notifications = r.notifications ?? [];
this.unread = r.unread ?? 0;
} catch {
// En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for.
}
}
/** Marks one read, or all of them when no id is given. */
async markRead(id) {
const previous = this.notifications;
const wasUnread = this.unread;
this.notifications = this.notifications.map((n) =>
!id || n.id === id ? { ...n, read: true } : n
);
this.unread = this.notifications.filter((n) => !n.read).length;
try {
await api.markNotificationsRead(id);
} catch {
this.notifications = previous;
this.unread = wasUnread;
}
}
/** Applies a change made by somebody else. */
#applyRemote(event) {
const task = event.payload;
switch (event.type) {
// Beskeder kommer på personens egen kanal, ikke projektets: en note delt
// med én person hører ikke til noget projekt, og der er intet rum at
// udsende den i.
case 'notification':
this.notifications = [
{ ...event.payload, read: false },
...this.notifications.filter((n) => n.id !== event.payload?.id)
].slice(0, 30);
this.unread = this.notifications.filter((n) => !n.read).length;
break;
// En rettelse, der er lagt oven i en besked, man allerede har. Tallet er
// uændret; linjen er en anden. Hentet frem for flettet, fordi serveren
// har det rigtige svar og listen er tredive rækker.
case 'notification.folded':
this.loadNotifications();
break;
case 'task.created':
case 'task.updated':
case 'task.completed':
+3
View File
@@ -1,6 +1,7 @@
<script>
import '../app.css';
import { app, theme, sidebar } from '$lib/stores.svelte.js';
import Notifications from '$lib/components/Notifications.svelte';
import { connectivity } from '$lib/connectivity.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -169,6 +170,8 @@
<kbd>⌘K</kbd>
</button>
<Notifications />
<button
class="theme"
onclick={() => theme.toggle()}
+257 -15
View File
@@ -192,9 +192,21 @@
// hentes kun for en note, man selv har lavet.
let shares = $state([]);
let shareCandidates = $state([]);
// De noter, denne note tager med sig, når den deles. Ejeren skal kunne se dem:
// en deling, der stille tager tre noter mere med, er rigtig og alligevel ikke
// til at overskue.
let shareFollows = $state([]);
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);
// The whole of sharing — a project and named people — lives in a popover behind
@@ -223,6 +235,10 @@
// under den nye.
shares = [];
shareCandidates = [];
shareFollows = [];
shareInvites = [];
shareEmail = '';
shareInviteLink = '';
sharePick = '';
if (!id || selected.created_by !== app.user?.id) return;
let alive = true;
@@ -232,6 +248,8 @@
if (!alive || selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
})
.catch(() => {});
return () => {
@@ -246,10 +264,20 @@
const person = shareCandidates.find((p) => p.id === sharePick);
shareBusy = true;
try {
await api.shareNote(selected.id, sharePick, shareRole);
const r = await api.shareNote(selected.id, sharePick, shareRole);
shares = [...shares, { user: person, role: shareRole }].sort((a, b) => byName(a.user, b.user));
shareCandidates = shareCandidates.filter((p) => p.id !== sharePick);
app.toast(t('notes.sharedToast', { name: person?.name ?? '' }));
shareFollows = r?.follows ?? shareFollows;
// Sagt frem for stiltiende. Delingen tog flere noter med, og den, der
// delte, skal vide hvor mange, uden at skulle læse panelet efter.
app.toast(
shareFollows.length
? t('notes.sharedWithFollowers', {
name: person?.name ?? '',
count: String(shareFollows.length)
})
: t('notes.sharedToast', { name: person?.name ?? '' })
);
sharePick = '';
} catch (e) {
app.toast(humanMessage(e));
@@ -258,6 +286,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);
@@ -1024,25 +1100,68 @@
></span>
{sh.user.name}
</span>
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
<!-- En adgang, der er fulgt med et link, er ikke valgt her og
kan ikke tages tilbage her: den følger den note, der peger
på denne. Rollen og krydset ville begge love noget, det
næste opslag rullede tilbage — så de står ikke. -->
{#if sh.linked}
<span class="viafollow"
>{t('notes.shareVia', { note: sh.via ?? '' })}</span
>
{:else}
<select
value={sh.role}
onchange={(e) => changeShareRole(sh.user.id, e.currentTarget.value)}
>
<option value="viewer">{t('notes.shareRoleViewer')}</option>
<option value="editor">{t('notes.shareRoleEditor')}</option>
</select>
<button
class="unshare"
onclick={() => removeShare(sh.user.id)}
title={t('notes.shareRemove')}
aria-label={t('notes.shareRemove')}>×</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<span class="none">{t('notes.shareNobody')}</span>
{/if}
{#if shareFollows.length}
<!-- Hvad delingen tager med sig. Et link i en delt note skal føre
samme sted hen for begge, og det kræver, at det, den peger på,
også er delt — men så skal det stå her, hvor den, der deler,
kan se det. -->
<p class="follows">
{t('notes.shareFollows')}
{#each shareFollows as f, i (f.note_id)}<button
class="followlink"
onclick={() => openLink({ kind: 'note', target_id: f.title })}
>{f.title}</button
>{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}>
@@ -1064,6 +1183,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}
@@ -1910,6 +2063,95 @@
background: var(--surface-raised);
}
/* Adgang, der er fulgt med et link. Sat i stedet for rollen og krydset, altså
yderst til højre, hvor øjet allerede leder efter "hvad kan denne række". */
.sharelist .viafollow {
margin-left: auto;
color: var(--ink-faint);
font-size: var(--text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hvad delingen tager med sig. Under listen af folk, fordi det gælder dem alle
— ikke én linje pr. person, som ville sige det samme tre gange. */
.follows {
margin: var(--s1) 0 0;
font-size: var(--text-xs);
color: var(--ink-muted);
line-height: 1.5;
}
.followlink {
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.followlink:hover {
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;