Merge pull request #91 from LINUXexpert-org/share-files

Share files and folders with other people
This commit is contained in:
LINUXexpert.org
2026-08-27 09:29:20 -07:00
committed by GitHub
6 changed files with 96 additions and 11 deletions
+4 -4
View File
@@ -193,9 +193,9 @@ const cards: Obj[] = people.slice(0, 6).map((p, i) => {
}); });
const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" }));
const fileNodes: Obj[] = [ const fileNodes: Obj[] = [
{ id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" }, { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" },
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
]; ];
function fr() { function fr() {
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
@@ -727,7 +727,7 @@ const handlers: Record<string, Handler> = {
"FileNode/get": genericGet(fileNodes), "FileNode/get": genericGet(fileNodes),
"FileNode/set": (a) => { "FileNode/set": (a) => {
return genericSet(fileNodes, "f", (o) => { return genericSet(fileNodes, "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
// Without nodeType, a node is a directory precisely when it carries no // Without nodeType, a node is a directory precisely when it carries no
// file properties. Keep it internally so query and get stay consistent. // file properties. Keep it internally so query and get stay consistent.
if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory"; if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory";
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isShared } from "@/lib/filenode";
/**
* The one thing about file sharing that a mock would never have told us.
*
* Stalwart 0.16.19 answers `shareWith` as `{}` for a node shared with nobody,
* not `null` — every unshared node in a live account came back that way on
* 2026-08-27. A truthiness test on the property is therefore true for every
* node the server has ever returned, and a badge driven by one would report
* the entire account as shared while being, technically, about the right
* property.
*/
describe("whether a node is shared", () => {
it("treats the empty object Stalwart sends as not shared", () => {
expect(isShared({ shareWith: {} })).toBe(false);
});
it("treats a missing or null shareWith as not shared", () => {
expect(isShared({ shareWith: null })).toBe(false);
expect(isShared({})).toBe(false);
});
it("is shared once a principal is on it", () => {
expect(isShared({ shareWith: { p1: { mayRead: true } } as never })).toBe(true);
});
it("stays shared when the rights granted are all false", () => {
// An entry with nothing enabled is still an entry: the principal is on the
// list, and the owner should see that rather than an empty-looking folder.
expect(isShared({ shareWith: { p1: { mayRead: false } } as never })).toBe(true);
});
});
+14 -2
View File
@@ -8,11 +8,11 @@
* separate ones. ihasmail requires 0.16 now — sign-in refuses anything older — * separate ones. ihasmail requires 0.16 now — sign-in refuses anything older —
* so a node has one shape and there is nothing left to detect. * so a node has one shape and there is nothing left to detect.
*/ */
import type { Id } from "@/jmap/types"; import type { FileNode, Id } from "@/jmap/types";
/** Properties to request for a node. */ /** Properties to request for a node. */
export function fileNodeProps(): string[] { export function fileNodeProps(): string[] {
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable", "nodeType"]; return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
} }
/** Create-arguments for a directory. */ /** Create-arguments for a directory. */
@@ -24,3 +24,15 @@ export function directoryCreate(parentId: Id | null, name: string): Record<strin
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> { export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
return { parentId, name, blobId, type, nodeType: "file" }; return { parentId, name, blobId, type, nodeType: "file" };
} }
/**
* Whether a node is shared with anyone.
*
* Stalwart answers `shareWith` as `{}` for "nobody", not `null` — confirmed
* against 0.16.19 on 2026-08-27, where every unshared node in the account came
* back that way. So a truthiness test passes for every node ever returned, and
* a badge driven by one would say the whole account is shared. Count the keys.
*/
export function isShared(node: Pick<FileNode, "shareWith">): boolean {
return Object.keys(node.shareWith ?? {}).length > 0;
}
+15
View File
@@ -21,6 +21,7 @@ interface FilesState {
rename(id: Id, name: string): Promise<void>; rename(id: Id, name: string): Promise<void>;
move(id: Id, parentId: Id | null): Promise<void>; move(id: Id, parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>; destroy(ids: Id[]): Promise<void>;
refresh(ids: Id[]): Promise<void>;
pathTo(id: Id | null): FileNode[]; pathTo(id: Id | null): FileNode[];
applyChanges(types: Set<string>): void; applyChanges(types: Set<string>): void;
} }
@@ -129,6 +130,20 @@ export const useFiles = create<FilesState>((set, get) => ({
await get().loadChildren(parentId); await get().loadChildren(parentId);
}, },
/* Re-read named nodes in place. Sharing changes one property of one node and
nothing about which folder it sits in, so reloading the level around it
would be a bigger round trip to land in the same place. */
async refresh(ids) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids, properties: fileNodeProps() });
set((s) => {
const nodes = { ...s.nodes };
for (const n of res.list) nodes[n.id] = n;
return { nodes };
});
},
async rename(id, name) { async rename(id, name) {
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } }); const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
+7 -2
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Trash2, Upload, FolderInput } from "lucide-react"; import { ChevronRight, Download, File, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
import { useFiles } from "@/store/files"; import { useFiles } from "@/store/files";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import type { FileNode } from "@/jmap/types"; import type { FileNode } from "@/jmap/types";
import { formatSize, formatListDate } from "@/lib/format"; import { formatSize, formatListDate } from "@/lib/format";
import { isShared } from "@/lib/filenode";
import { ShareDialog } from "../settings/ShareDialog";
import { Empty, Spinner } from "@/ui/misc"; import { Empty, Spinner } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog"; import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
@@ -19,6 +21,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
const menu = useMenu(); const menu = useMenu();
const [menuNode, setMenuNode] = useState<FileNode | null>(null); const [menuNode, setMenuNode] = useState<FileNode | null>(null);
const [moveNode, setMoveNode] = useState<FileNode | null>(null); const [moveNode, setMoveNode] = useState<FileNode | null>(null);
const [shareNode, setShareNode] = useState<FileNode | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -94,7 +97,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
<tbody> <tbody>
{nodes.map((n) => ( {nodes.map((n) => (
<tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}> <tr key={n.id} className={selected === n.id ? "selected" : ""} onClick={() => setSelected(n.id)} onDoubleClick={() => (n.nodeType === "directory" ? navigate(`/files/${n.id}`) : download(n))} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span></div></td> <td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label="Shared" />}</div></td>
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td> <td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td> <td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td> <td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label="Options"><MoreVertical size={16} /></button></td>
@@ -110,12 +113,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />} {menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label="Open" onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label="Download" onClick={() => download(menuNode)} />}
<MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} /> <MenuItem icon={<Pencil size={16} />} label="Rename" disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
<MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} /> <MenuItem icon={<FolderInput size={16} />} label="Move to…" onClick={() => setMoveNode(menuNode)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
<MenuSep /> <MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} /> <MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
</> </>
)} )}
</Popover> </Popover>
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />} {moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
{shareNode && <ShareDialog kind="FileNode" id={shareNode.id} name={shareNode.name} shareWith={shareNode.shareWith ?? null} onClose={() => setShareNode(null)} />}
</div> </div>
); );
} }
+22 -3
View File
@@ -4,11 +4,13 @@ import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
import { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client"; import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types"; import type { Id, Principal } from "@/jmap/types";
type Kind = "Mailbox" | "Calendar" | "AddressBook"; /* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = { const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
Mailbox: [ Mailbox: [
@@ -38,15 +40,27 @@ const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
{ key: "mayShare", label: "Share" }, { key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" }, { key: "mayDelete", label: "Delete" },
], ],
// Stalwart 0.16.19 returns all six on a node of your own (2026-08-27).
FileNode: [
{ key: "mayRead", label: "Read" },
{ key: "mayAddChildren", label: "Add files" },
{ key: "mayModifyContent", label: "Edit contents" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "mayShare", label: "Share" },
],
}; };
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = { const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] }, Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] }, Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] }, AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
// An editor can fill a folder and change what is in it, but not rename or
// delete the folder they were given -- those stay with whoever shared it.
FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] },
}; };
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */ /** Share a mailbox / calendar / address book / file node with other principals (JMAP Sharing, RFC 9670). */
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) { export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
const principals = useContacts((s) => s.principals); const principals = useContacts((s) => s.principals);
const loadPrincipals = useContacts((s) => s.loadPrincipals); const loadPrincipals = useContacts((s) => s.loadPrincipals);
@@ -67,7 +81,11 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
const save = async () => { const save = async () => {
setBusy(true); setBusy(true);
try { try {
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId; const accountId =
kind === "Mailbox" ? useMail.getState().accountId
: kind === "Calendar" ? useCalendar.getState().accountId
: kind === "FileNode" ? useFiles.getState().accountId
: useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } }); const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err)); if (err) throw new Error(setErrorMessage(err));
@@ -75,6 +93,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
if (kind === "Mailbox") void useMail.getState().loadMailboxes(); if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars(); if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks(); if (kind === "AddressBook") void useContacts.getState().loadBooks();
if (kind === "FileNode") void useFiles.getState().refresh([id]);
onClose(); onClose();
} catch (err) { } catch (err) {
toast.error((err as Error).message); toast.error((err as Error).message);