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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab77024ec9
commit
8ee9fbedfb
@@ -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.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
const KINDS = {
|
||||
assigned: 'notif.assigned',
|
||||
'note.changed': 'notif.noteChanged',
|
||||
'note.shared': 'notif.noteShared',
|
||||
comment: 'notif.comment'
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const da = {
|
||||
'notif.markAllRead': 'Markér alle som læst',
|
||||
'notif.assigned': '{name} gav dig en opgave',
|
||||
'notif.noteChanged': '{name} rettede en note',
|
||||
'notif.noteShared': '{name} delte en note med dig',
|
||||
'notif.comment': '{name} kommenterede',
|
||||
'notif.justNow': 'lige nu',
|
||||
'notif.minutes': '{n} min. siden',
|
||||
@@ -50,6 +51,17 @@ export const da = {
|
||||
'notes.shareFollows': 'Følger med:',
|
||||
'notes.shareVia': 'følger med “{note}”',
|
||||
'notes.unsharedPerson': '{name} kan ikke længere se noten.',
|
||||
// Deling med en adresse. Ordene skal kunne bære begge udfald — en deling nu
|
||||
// eller en invitation, der venter — uden at love det ene.
|
||||
'notes.shareByEmail': 'Del med en e-mailadresse',
|
||||
'notes.shareEmailPlaceholder': 'navn@eksempel.dk',
|
||||
'notes.shareRole': 'Rolle',
|
||||
'notes.shareInvite': 'Inviter',
|
||||
'notes.invited': 'Invitationen er sendt til {email}.',
|
||||
'notes.invitePending': 'inviteret',
|
||||
'notes.inviteRevoke': 'Træk invitationen tilbage',
|
||||
'notes.inviteRevoked': 'Invitationen til {email} er trukket tilbage.',
|
||||
'notes.inviteNoMail': 'Instansen kan ikke sende post. Giv linket videre selv:',
|
||||
'notes.shareOwnerOnly': 'Kun ejeren kan dele noten.',
|
||||
'notes.save': 'Gem',
|
||||
'notes.format': 'Formatér',
|
||||
|
||||
@@ -27,6 +27,7 @@ export const en = {
|
||||
'notif.markAllRead': 'Mark all as read',
|
||||
'notif.assigned': '{name} gave you a task',
|
||||
'notif.noteChanged': '{name} edited a note',
|
||||
'notif.noteShared': '{name} shared a note with you',
|
||||
'notif.comment': '{name} commented',
|
||||
'notif.justNow': 'just now',
|
||||
'notif.minutes': '{n} min ago',
|
||||
@@ -47,6 +48,17 @@ export const en = {
|
||||
'notes.shareFollows': 'Comes along:',
|
||||
'notes.shareVia': 'came along with “{note}”',
|
||||
'notes.unsharedPerson': '{name} can no longer see the note.',
|
||||
// Sharing with an address. The words have to carry both outcomes — a share now,
|
||||
// or an invitation that waits — without promising either one.
|
||||
'notes.shareByEmail': 'Share with an email address',
|
||||
'notes.shareEmailPlaceholder': 'name@example.com',
|
||||
'notes.shareRole': 'Role',
|
||||
'notes.shareInvite': 'Invite',
|
||||
'notes.invited': 'The invitation has been sent to {email}.',
|
||||
'notes.invitePending': 'invited',
|
||||
'notes.inviteRevoke': 'Withdraw the invitation',
|
||||
'notes.inviteRevoked': 'The invitation to {email} has been withdrawn.',
|
||||
'notes.inviteNoMail': 'This instance cannot send mail. Pass the link on yourself:',
|
||||
'notes.shareOwnerOnly': 'Only the owner can share the note.',
|
||||
'notes.save': 'Save',
|
||||
'notes.format': 'Format',
|
||||
|
||||
@@ -199,6 +199,14 @@
|
||||
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
|
||||
@@ -228,6 +236,9 @@
|
||||
shares = [];
|
||||
shareCandidates = [];
|
||||
shareFollows = [];
|
||||
shareInvites = [];
|
||||
shareEmail = '';
|
||||
shareInviteLink = '';
|
||||
sharePick = '';
|
||||
if (!id || selected.created_by !== app.user?.id) return;
|
||||
let alive = true;
|
||||
@@ -238,6 +249,7 @@
|
||||
shares = r.shares ?? [];
|
||||
shareCandidates = r.candidates ?? [];
|
||||
shareFollows = r.follows ?? [];
|
||||
shareInvites = r.invites ?? [];
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -274,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);
|
||||
@@ -1083,6 +1143,25 @@
|
||||
>{i < shareFollows.length - 1 ? ', ' : ''}{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{#if shareInvites.length}
|
||||
<!-- Sendt, ikke taget imod endnu. Adressen er alt, hvad der findes
|
||||
om personen indtil de dukker op — der er hverken navn eller
|
||||
farve at vise, og det er selve pointen med linjen. -->
|
||||
<ul class="invited">
|
||||
{#each shareInvites as inv (inv.id)}
|
||||
<li>
|
||||
<span class="who">{inv.email}</span>
|
||||
<span class="pending">{t('notes.invitePending')}</span>
|
||||
<button
|
||||
class="unshare"
|
||||
onclick={() => revokeInvite(inv.id, inv.email)}
|
||||
title={t('notes.inviteRevoke')}
|
||||
aria-label={t('notes.inviteRevoke')}>×</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if shareCandidates.length}
|
||||
<div class="addshare">
|
||||
<select bind:value={sharePick}>
|
||||
@@ -1104,6 +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}
|
||||
@@ -1982,6 +2095,63 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* De inviterede. Samme form som listen ovenover, fordi det er den samme slags
|
||||
linje — en person, noten er delt med — og forskellen er, at de ikke er kommet
|
||||
endnu. Den forskel siges med ordet til højre, ikke med en anden form. */
|
||||
.invited {
|
||||
list-style: none;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.invited li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.invited .who {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invited .pending {
|
||||
margin-left: auto;
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Adressefeltet får sin egen række af samme grund som personvælgeren: en
|
||||
e-mailadresse klemt ned ved siden af en rolle og en knap bliver et felt, man
|
||||
ikke kan læse det, man har skrevet i. */
|
||||
.byemail input {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
}
|
||||
|
||||
.invitelink {
|
||||
margin: var(--s1) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.invitelink input {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s1);
|
||||
}
|
||||
|
||||
.addshare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user