feat(noter): nævn en person i en note, og den er delt med dem

At skrive nogens navn i en note *er* at ville have dem til at læse den.
Indtil nu var det to handlinger — skriv navnet, åbn panelet, find
personen, del — og den anden blev glemt, så noten stod med en besked til
en, der ikke kunne se den.

Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem.
Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter.

Slået op i navnene frem for tolket ud af teksten. Et navn kan have
mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også
fange sætningen bagefter: `@Sofie i morgen` ville blive til en person,
der hed noget i den retning. Her er der ikke noget at gætte — der står et
navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at
dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte
at læse. En mailadresse er ikke en omtale, det længste navn vinder, og
ejefaldets s hører til sætningen.

Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som
links: en note gemmes ved hver tastepause, så uden sammenligningen var
hver gemning en ny deling og en ny besked om det samme navn.

Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke
give den videre — ellers kunne den, ejeren har delt med, skrive et navn
og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er
den samme regel, panelet følger.

Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste,
der opfylder sætningen, er det rigtige at give. En rolle, ejeren har
hævet i panelet, sænkes ikke igen af en omtale.

Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte
dig" og ikke også "rettede en note" om den samme gemning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 12:08:12 +02:00
co-authored by Claude Opus 5
parent 8ee9fbedfb
commit d8f5f6e236
12 changed files with 528 additions and 6 deletions
+38 -2
View File
@@ -20,7 +20,10 @@
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, notes = [], onchange, onsave, onopennote } = $props();
// `people` er dem, et `@` kan betyde: dem, noten kan deles med. Givet ind frem
// for hentet her, fordi siden allerede har listen — den står i delepanelet — og
// to hentninger af den samme liste er to, der kan blive uenige.
let { note, notes = [], people = [], onchange, onsave, onopennote } = $props();
let editor;
// Forslagslisten, så tastaturvalget kan rulle den frem.
@@ -87,6 +90,7 @@
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
{ mark: '@', what: 'notes.syntaxPerson' },
{ mark: '⌘B', what: 'notes.bold' },
{ mark: '⌘U', what: 'notes.underline' }
];
@@ -509,6 +513,19 @@
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
}
// Et navn kan have et mellemrum i sig, så ordet efter `@` må tage ét med:
// "Sofie J" skal kunne snævre listen ind til Sofie Jensen. Mere end ét ville
// læse sætningen efter navnet med.
const person = /(?:^|\s)@([\p{L}\p{N}._-]*(?:\s[\p{L}\p{N}._-]*)?)$/u.exec(before);
if (person) {
return {
kind: 'person',
node,
start: selection.anchorOffset - person[1].length,
term: person[1]
};
}
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
if (project) {
return {
@@ -562,7 +579,12 @@
// pixels for hvert bogstav, mens listen snævres ind.
if (!suggestions.length) menuAt = tagPosition(partial);
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
suggestions =
partial.kind === 'note'
? noteMatches(term)
: partial.kind === 'person'
? personMatches(term)
: projectMatches(term);
chosen = 0;
}
@@ -586,6 +608,16 @@
.sort((a, b) => rank(a.label, b.label, term));
}
// Dem, noten kan deles med. Ingen filtrering på, hvem der allerede har den: at
// nævne en, der læser med i forvejen, er en almindelig ting at gøre — og
// omtalen giver dem besked, selv om den ikke giver dem noget nyt.
function personMatches(term) {
return people
.filter((p) => p.name && p.name.toLowerCase().includes(term))
.map((p) => ({ id: p.id, kind: 'person', label: p.name, color: p.avatar_color }))
.sort((a, b) => rank(a.label, b.label, term));
}
function rank(a, b, term) {
const al = a.toLowerCase();
const bl = b.toLowerCase();
@@ -1011,6 +1043,10 @@
{#if item.kind === 'note'}
<span class="dot note" aria-hidden="true">[[</span>
{item.label}
{:else if item.kind === 'person'}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@{item.label}
{:else}
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
></span>
@@ -24,6 +24,7 @@
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
comment: 'notif.comment'
};
+5
View File
@@ -31,6 +31,7 @@ export const da = {
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
@@ -62,6 +63,10 @@ export const da = {
'notes.inviteRevoke': 'Træk invitationen tilbage',
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
// Omtale. `@Navn` i en note deler den med den, der bliver nævnt — sagt her,
// fordi det sker uden at nogen åbner delepanelet.
'notes.syntaxPerson': 'en person',
'notes.mentionShared': 'Noten er delt med {names}, fordi du nævnte dem.',
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
'notes.save': 'Gem',
'notes.format': 'Formatér',
+5
View File
@@ -28,6 +28,7 @@ export const en = {
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
@@ -59,6 +60,10 @@ export const en = {
'notes.inviteRevoke': 'Withdraw the invitation',
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
// A mention. `@Name` in a note shares it with the person named — said here,
// because it happens without anybody opening the share panel.
'notes.syntaxPerson': 'a person',
'notes.mentionShared': 'The note has been shared with {names}, because you named them.',
'notes.shareOwnerOnly': 'Only the owner can share the note.',
'notes.save': 'Save',
'notes.format': 'Format',
+50
View File
@@ -209,6 +209,21 @@
let shareInviteLink = $state('');
const ownsSelected = $derived(!!selected && selected.created_by === app.user?.id);
// Dem, et `@` kan betyde i den åbne note.
//
// I ejerens note er det dem, noten kan deles med, plus dem den allerede er delt
// med: en omtale af en, der læser med i forvejen, er en almindelig ting at
// skrive, og den giver dem besked selv om den ikke giver dem adgang.
//
// I en andens note er det dem, man deler projekter med. En medredaktørs omtale
// deler ikke noget — kun ejeren deler — så listen behøver ikke være instansens,
// og skal heller ikke være det.
const mentionable = $derived(
ownsSelected
? [...shareCandidates, ...shares.map((sh) => sh.user)]
: app.people.filter((p) => p.id !== app.user?.id)
);
// The whole of sharing — a project and named people — lives in a popover behind
// one button, so the note's footer stays a status and three buttons rather than
// a row that runs off its own width.
@@ -659,6 +674,7 @@
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
await mentionsShared(id);
} catch (e) {
app.toast(humanMessage(e));
} finally {
@@ -666,6 +682,39 @@
}
}
/**
* Hvem gemningen delte noten med, fordi de blev nævnt i den.
*
* Serveren siger det ikke i svaret på gemningen: en omtale er en sjælden ting
* blandt mange gemninger, og en tastepause skal ikke koste et opslag i
* delingerne, hver gang den falder. Panelet spørges i stedet — og kun når
* teksten faktisk har et `@` i sig, som er den samme betingelse serveren selv
* bruger for at kigge efter.
*
* Sagt frem for stiltiende. En note, der lige har delt sig selv med en, man
* skrev navnet på, skal sige det — også når panelet er lukket.
*/
async function mentionsShared(id) {
if (!ownsSelected || !draft.includes('@')) return;
const known = new Set(shares.map((sh) => sh.user.id));
try {
const r = await api.noteShares(id);
if (selected?.id !== id) return;
shares = r.shares ?? [];
shareCandidates = r.candidates ?? [];
shareFollows = r.follows ?? [];
shareInvites = r.invites ?? [];
const added = shares.filter((sh) => !known.has(sh.user.id));
if (added.length) {
app.toast(
t('notes.mentionShared', { names: added.map((sh) => sh.user.name).join(', ') })
);
}
} catch {
// En liste, der ikke kunne hentes, er ikke en gemning, der gik galt.
}
}
/**
* Favourites are the pinned ones. The list already puts them first, so marking
* one is the same act as saying "keep this where I can see it" — one idea, not
@@ -1028,6 +1077,7 @@
<NoteEditor
note={selected}
notes={notes}
people={mentionable}
onopennote={(title) => openLink({ kind: 'note', target_id: title })}
onchange={(body) => {
draft = body;