sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s

The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:01:53 -04:00
co-authored by Claude Opus 5.5
parent f100e5ef85
commit f53d377766
24 changed files with 911 additions and 50 deletions
+10 -1
View File
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type { DeviceToken, ImportResult, Repo } from "./repo";
import type { DeviceToken, ImportResult, Member, NoteShare, Repo } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -91,4 +91,13 @@ export const local: Repo = {
remove: (id) => invoke<void>("saved_filters_remove", { id }),
rename: (id, name) => invoke<SavedFilter>("saved_filters_rename", { id, name }),
},
// Sharing is between accounts on a server; the desktop gets it with sync (#5175).
// The Share controls are not shown here, so these only answer a stray call.
shares: {
directory: () => Promise.resolve<Member[]>([]),
list: () => Promise.resolve<NoteShare[]>([]),
share: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
unshare: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
},
};
+28
View File
@@ -46,6 +46,24 @@ export interface ChecklistItemChanges {
}
// ---- sharing (#5174) ---------------------------------------------------------
/** A person on this instance, as the member directory shows them. */
export interface Member {
id: string;
display_name: string;
email: string;
}
export type SharePermission = "view" | "edit";
export interface NoteShare {
id: string;
member: Member;
permission: SharePermission;
created_at: string | null;
}
export interface ImportResult {
source: string;
imported: number;
@@ -116,6 +134,15 @@ export interface NotesRepo {
titles(): Promise<TitleEntry[]>;
}
export interface SharesRepo {
/** Everyone on the instance but you. */
directory(): Promise<Member[]>;
list(noteId: string): Promise<NoteShare[]>;
/** Share with one member, or change their permission. Answers the note's shares. */
share(noteId: string, userId: string, permission: SharePermission): Promise<NoteShare[]>;
unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
}
export interface SavedFiltersRepo {
list(): Promise<SavedFilter[]>;
create(name: string, params: NoteFacets): Promise<SavedFilter>;
@@ -130,4 +157,5 @@ export interface Repo {
labels: LabelsRepo;
notes: NotesRepo;
savedFilters: SavedFiltersRepo;
shares: SharesRepo;
}
+12
View File
@@ -15,7 +15,9 @@ import type { PublicConfig } from "../stores/config";
import type {
DeviceToken,
ImportResult,
Member,
NoteChanges,
NoteShare,
NoteCreateInput,
NoteListQuery,
ChecklistItemChanges,
@@ -35,6 +37,7 @@ function notesQuery(q: NoteListQuery): string {
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
if (q.facets?.created_before) params.set("created_before", q.facets.created_before);
if (q.facets?.shared) params.set("shared", q.facets.shared);
if (q.sort) params.set("sort", q.sort);
return params.toString();
}
@@ -115,4 +118,13 @@ export const rest: Repo = {
remove: (id) => api.del<void>(`/api/saved-filters/${id}`),
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
},
shares: {
directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members,
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
share: async (noteId, userId, permission) =>
(await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares,
unshare: async (noteId, shareId) =>
(await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares,
},
};
+2
View File
@@ -13,6 +13,7 @@ import Icon from "./Icon.vue";
import ExportNotes from "./ExportNotes.vue";
import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue";
import ShareDialog from "./ShareDialog.vue";
import { isDesktop } from "../desktop/bridge";
import { useLensName } from "../composables/useLensName";
import { facetsToQuery } from "../notes/facets";
@@ -552,6 +553,7 @@ async function signOut() {
<LabelsModal v-if="managing" @close="managing = false" />
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<ShareDialog v-if="ui.shareNoteId" :note-id="ui.shareNoteId" @close="ui.closeShare()" />
<BaseModal
v-if="showShortcuts"
+15
View File
@@ -9,6 +9,7 @@ import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import Icon from "./Icon.vue";
import { errorMessage } from "../api/errors";
import { isDesktop } from "../desktop/bridge";
// A dead-simple facet bar over the board: color + labels + has-reminder
// + has-attachment + created-date range. The URL query IS the state, so a
@@ -43,6 +44,12 @@ function toggleReminder() {
function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true });
}
// Sharing is between accounts on a server, so the offline desktop has no shared notes
// to narrow to (#5174).
const sharingAvailable = !isDesktop();
function toggleShared() {
patch({ shared: facets.value.shared ? undefined : "with_me" });
}
function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value;
@@ -129,6 +136,14 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment
</button>
<button
v-if="sharingAvailable"
type="button"
:class="[chipBase, facets.shared ? chipOn : chipOff]"
@click="toggleShared"
>
Shared with me
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
+1
View File
@@ -29,6 +29,7 @@ const paths: Record<string, string> = {
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
};
</script>
+50 -4
View File
@@ -16,6 +16,9 @@ import {
} from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config";
import { useUiStore } from "../stores/ui";
import { canEditText, isOwnNote } from "../notes/sharing";
import { isDesktop } from "../desktop/bridge";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
@@ -32,6 +35,21 @@ const emit = defineEmits<{
}>();
const notes = useNotesStore();
const config = useConfigStore();
const ui = useUiStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
// the owner's controls; at `view` its checkboxes are inert too. Sharing needs a
// server, so the offline desktop has no Share button (its notes are all its own). ---
const own = computed(() => isOwnNote(props.note));
const editable = computed(() => canEditText(props.note));
const canShare = computed(() => own.value && !isDesktop());
const sharedLine = computed(() => {
if (!own.value) {
const who = props.note.shared_by?.display_name || "someone";
return props.note.permission === "view" ? `Shared by ${who} · view only` : `Shared by ${who}`;
}
return props.note.shared ? "Shared" : "";
});
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it.
@@ -105,7 +123,8 @@ const root = ref<HTMLElement | null>(null);
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed;
// Board order is the owner's; a shared note's place is the recipient's from #5176.
const canDrag = () => !!props.reorderable && !props.note.trashed && own.value;
const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id);
@@ -308,7 +327,12 @@ const tagColors = computed<Record<string, string>>(() => {
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
<MarkdownText
:text="bodyPreview"
:tag-colors="tagColors"
:toggleable="editable"
@toggle="toggleTask"
/>
</div>
<p
v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -329,7 +353,17 @@ const tagColors = computed<Record<string, string>>(() => {
two paragraphs instead of always after them. Rendering both would have shown
every list twice. -->
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<div v-if="sharedLine" class="mt-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
<Icon name="users" class="!h-3 !w-3" />
{{ sharedLine }}
</span>
</div>
<!-- A reminder is its owner's: someone the note is shared with isn't alerted by it. -->
<div v-if="note.remind_at && own" class="mt-2 flex flex-wrap items-center gap-1.5">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
@@ -424,7 +458,9 @@ const tagColors = computed<Record<string, string>>(() => {
<Icon name="grip" />
</button>
<!-- Every action here is the owner's, so a shared note has no action set. -->
<div
v-if="own"
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
>
<template v-if="note.trashed">
@@ -441,7 +477,17 @@ const tagColors = computed<Record<string, string>>(() => {
<Icon name="trash" />
</button>
</template>
<template v-else>
<template v-else-if="own">
<button
v-if="canShare"
type="button"
class="icon-btn"
title="Share"
aria-label="Share"
@click="ui.openShare(note.id)"
>
<Icon name="users" />
</button>
<button
type="button"
class="icon-btn"
+51 -9
View File
@@ -11,6 +11,9 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors";
import { canEditText, isOwnNote } from "../notes/sharing";
import { useUiStore } from "../stores/ui";
import { isDesktop } from "../desktop/bridge";
import {
afterEnter,
type EditorBlock,
@@ -114,6 +117,19 @@ const liveNote = computed<Note>(() =>
);
const bodyPlaceholder = "Take a note…";
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to
// change and nothing else is; at `view` it is read-only. The server enforces both —
// this only stops the editor offering what would be refused.
const ui = useUiStore();
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
const canShare = computed(() => own.value && !isCreate.value && !isDesktop());
const sharedLine = computed(() => {
if (own.value) return "";
const who = liveNote.value.shared_by?.display_name || "someone";
return editable.value ? `Shared by ${who} · you can edit the text` : `Shared by ${who} · view only`;
});
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
@@ -353,6 +369,7 @@ function onProseInput(index: number, e: Event): void {
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
if (!editable.value) return;
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
@@ -369,6 +386,7 @@ function onProseKeydown(e: KeyboardEvent): void {
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
if (!editable.value) return;
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
@@ -383,6 +401,7 @@ function onTaskEnter(index: number): void {
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
if (!editable.value) return;
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
@@ -487,6 +506,7 @@ async function onFileChange(e: Event) {
input.value = "";
}
async function onPaste(e: ClipboardEvent) {
if (!own.value) return;
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile();
if (file) {
@@ -563,12 +583,17 @@ function revPreview(rev: NoteRevision): string {
@paste="onPaste"
>
<div class="flex flex-col gap-2 p-4">
<p v-if="sharedLine" class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
<Icon name="users" class="!h-3.5 !w-3.5" />
{{ sharedLine }}
</p>
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
<template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button
v-if="own"
type="button"
class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove attachment"
@@ -584,6 +609,7 @@ function revPreview(rev: NoteRevision): string {
>
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
<button
v-if="own"
type="button"
class="text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@@ -603,6 +629,7 @@ function revPreview(rev: NoteRevision): string {
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
<button
v-if="own"
type="button"
class="ml-1 text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@@ -631,7 +658,7 @@ function revPreview(rev: NoteRevision): string {
v-for="p in liveNote.previews"
:key="p.id"
:preview="p"
:removable="!liveNote.trashed"
:removable="!liveNote.trashed && own"
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
@@ -646,6 +673,7 @@ function revPreview(rev: NoteRevision): string {
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:disabled="!editable"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
@@ -653,6 +681,7 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
:readonly="!editable"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@@ -660,6 +689,7 @@ function revPreview(rev: NoteRevision): string {
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
v-if="editable"
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@@ -673,7 +703,8 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
:readonly="!editable"
:placeholder="i === 0 && editable ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@@ -707,7 +738,7 @@ function revPreview(rev: NoteRevision): string {
</span>
</div>
<div v-if="richEnabled" class="flex items-center gap-2 pt-1">
<div v-if="richEnabled && own" class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" />
<input
type="datetime-local"
@@ -725,7 +756,7 @@ function revPreview(rev: NoteRevision): string {
</button>
</div>
<div v-if="richEnabled && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<div v-if="richEnabled && own && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<label class="text-neutral-400">Repeat</label>
<select
:value="liveNote.recurrence ?? ''"
@@ -785,7 +816,7 @@ function revPreview(rev: NoteRevision): string {
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && own"
type="button"
class="icon-btn"
title="Attach a file"
@@ -796,7 +827,7 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
v-if="!liveNote.trashed && editable"
type="button"
class="icon-btn"
title="Add a checklist"
@@ -806,12 +837,23 @@ function revPreview(rev: NoteRevision): string {
<Icon name="checkbox" />
</button>
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && own"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="!isCreate"
v-if="canShare && !liveNote.trashed"
type="button"
class="icon-btn"
:class="liveNote.shared ? 'text-brand-700 dark:text-brand' : ''"
title="Share"
aria-label="Share"
@click="ui.openShare(liveNote.id)"
>
<Icon name="users" />
</button>
<button
v-if="!isCreate && own"
type="button"
class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
@@ -822,7 +864,7 @@ function revPreview(rev: NoteRevision): string {
>
<Icon name="history" />
</button>
<template v-if="!isCreate && !liveNote.trashed">
<template v-if="!isCreate && !liveNote.trashed && own">
<button
type="button"
class="icon-btn"
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { repo } from "../adapters";
import type { Member, NoteShare, SharePermission } from "../adapters/repo";
import { errorMessage } from "../api/errors";
import { useNotesStore } from "../stores/notes";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
// Share a note with other people on this instance (#5174). The owner picks a member
// from the directory and says whether they may only read it or also change its text.
// Only the owner opens this; the server refuses everyone else regardless.
const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{ (e: "close"): void }>();
const notes = useNotesStore();
const PERMISSIONS: { value: SharePermission; label: string }[] = [
{ value: "view", label: "Can view" },
{ value: "edit", label: "Can edit" },
];
const members = ref<Member[]>([]);
const shares = ref<NoteShare[]>([]);
const loading = ref(true);
const loadError = ref("");
const error = ref("");
const busy = ref(false);
const pick = ref("");
const permission = ref<SharePermission>("view");
/** The people it isn't shared with yet. */
const available = computed(() => {
const taken = new Set(shares.value.map((s) => s.member.id));
return members.value.filter((m) => !taken.has(m.id));
});
async function load() {
loading.value = true;
loadError.value = "";
try {
[members.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
} catch (e) {
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
} finally {
loading.value = false;
}
}
async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
busy.value = true;
error.value = "";
try {
shares.value = await fn();
notes.setShared(props.noteId, shares.value.length > 0);
} catch (e) {
error.value = errorMessage(e, fallback);
} finally {
busy.value = false;
}
}
async function add() {
if (!pick.value) return;
const userId = pick.value;
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note.");
if (!error.value) pick.value = "";
}
function change(share: NoteShare, next: SharePermission) {
void run(() => repo.shares.share(props.noteId, share.member.id, next), "Couldn't change that.");
}
function remove(share: NoteShare) {
void run(() => repo.shares.unshare(props.noteId, share.id), "Couldn't stop sharing.");
}
const selectClass =
"rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-800";
onMounted(load);
</script>
<template>
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Share note</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-4 p-4">
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
<div v-else-if="loadError" class="py-4 text-center">
<p class="text-sm text-red-600 dark:text-red-400">{{ loadError }}</p>
<button
type="button"
class="mt-2 rounded-md border border-neutral-300 px-3 py-1 text-sm hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="load"
>
Retry
</button>
</div>
<template v-else>
<p v-if="!members.length" class="text-sm text-neutral-500 dark:text-neutral-400">
No one else has an account here yet. An admin can invite people from Settings.
</p>
<form v-else-if="available.length" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person" :disabled="busy">
<option value="" disabled>Choose someone…</option>
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option>
</select>
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<button
type="submit"
class="rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60"
:disabled="busy || !pick"
>
Share
</button>
</form>
<p v-if="error" role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Shared with</p>
<p v-if="!shares.length" class="text-sm text-neutral-400">Only you.</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ s.member.display_name }}</p>
<p class="truncate text-xs text-neutral-400">{{ s.member.email }}</p>
</div>
<select
:value="s.permission"
:class="selectClass"
:aria-label="`Permission for ${s.member.display_name}`"
:disabled="busy"
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
>
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<button
type="button"
class="icon-btn"
:title="`Stop sharing with ${s.member.display_name}`"
:aria-label="`Stop sharing with ${s.member.display_name}`"
:disabled="busy"
@click="remove(s)"
>
<Icon name="close" />
</button>
</li>
</ul>
</div>
</template>
</div>
</BaseModal>
</template>
+3
View File
@@ -22,6 +22,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
if (after) f.created_after = after;
const before = one(q.created_before);
if (before) f.created_before = before;
if (one(q.shared) === "with_me") f.shared = "with_me";
return f;
}
@@ -33,6 +34,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
if (f.has_attachment) q.has_attachment = "true";
if (f.created_after) q.created_after = f.created_after;
if (f.created_before) q.created_before = f.created_before;
if (f.shared) q.shared = f.shared;
return q;
}
@@ -44,5 +46,6 @@ export function facetCount(f: NoteFacets): number {
if (f.has_reminder) n++;
if (f.has_attachment) n++;
if (f.created_after || f.created_before) n++;
if (f.shared) n++;
return n;
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { canEditText, isOwnNote } from "./sharing";
import { facetCount, facetsFromQuery, facetsToQuery } from "./facets";
import type { Note } from "../stores/notes";
const note = (extra: Partial<Note>): Note => ({ ...({} as Note), ...extra });
describe("how a note is held", () => {
it("is your own when the source says nothing of sharing", () => {
expect(isOwnNote(note({}))).toBe(true);
expect(canEditText(note({}))).toBe(true);
});
it("lets an edit share change the text but not own the note", () => {
expect(isOwnNote(note({ permission: "edit" }))).toBe(false);
expect(canEditText(note({ permission: "edit" }))).toBe(true);
});
it("leaves a view share read-only", () => {
expect(canEditText(note({ permission: "view" }))).toBe(false);
});
});
describe("the Shared with me facet", () => {
it("round-trips through the URL and counts as one filter", () => {
const facets = facetsFromQuery({ shared: "with_me" });
expect(facets).toEqual({ shared: "with_me" });
expect(facetsToQuery(facets)).toEqual({ shared: "with_me" });
expect(facetCount(facets)).toBe(1);
});
it("ignores a value it doesn't know", () => {
expect(facetsFromQuery({ shared: "by_me" })).toEqual({});
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Note } from "../stores/notes";
// How the signed-in person holds a note (#5174). The owner does everything; someone it
// is shared with at `edit` changes the body and checklist; at `view`, nothing. A note
// with no permission came from a source without sharing (the offline desktop): it is
// the person's own.
export function isOwnNote(note: Note): boolean {
return (note.permission ?? "owner") === "owner";
}
export function canEditText(note: Note): boolean {
return (note.permission ?? "owner") !== "view";
}
+21
View File
@@ -13,8 +13,14 @@ export interface NoteFacets {
has_attachment?: boolean;
created_after?: string;
created_before?: string;
/** "with_me": only notes other people have shared with you (#5174). */
shared?: "with_me";
}
/** How the signed-in person holds a note. A note from a source that knows nothing of
* sharing (the offline desktop) has none, and is theirs. */
export type NotePermission = "owner" | "edit" | "view";
export interface NoteLabel {
id: string;
name: string;
@@ -86,6 +92,13 @@ export interface Note {
previews: LinkPreview[];
created_at: string | null;
updated_at: string | null;
// Sharing (#5174). `edit` may change the body and checklist; everything else is the
// owner's. A recipient's copy arrives without the owner's labels.
permission?: NotePermission;
/** The owner's own note: whether it is shared with anyone. */
shared?: boolean;
/** Someone else's note: who shared it. */
shared_by?: { id: string; display_name: string } | null;
}
export const useNotesStore = defineStore("notes", () => {
@@ -108,6 +121,7 @@ export const useNotesStore = defineStore("notes", () => {
const inView =
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
if (!inView) return false;
if (activeFacets.value.shared === "with_me" && (n.permission ?? "owner") === "owner") return false;
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
return true;
}
@@ -262,9 +276,16 @@ export const useNotesStore = defineStore("notes", () => {
return note;
}
/** The Share dialog changed who a note is shared with; the card's indicator follows. */
function setShared(id: string, shared: boolean): void {
const note = items.value.find((n) => n.id === id);
if (note) note.shared = shared;
}
return {
items,
loading,
setShared,
view,
activeLabel,
activeFacets,
+13
View File
@@ -53,7 +53,20 @@ export const useUiStore = defineStore("ui", () => {
run?.();
}
// The note whose Share dialog is open (#5174), or null. One dialog for the app,
// mounted by the shell, so the card and the editor open the same one.
const shareNoteId = ref<string | null>(null);
function openShare(id: string) {
shareNoteId.value = id;
}
function closeShare() {
shareNoteId.value = null;
}
return {
shareNoteId,
openShare,
closeShare,
composeTick,
composeSeed,
requestCompose,