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
+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";
}