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
+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,