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
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:
@@ -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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user