Merge pull request #91 from LINUXexpert-org/share-files
Share files and folders with other people
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user