Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
89 lines
4.0 KiB
TypeScript
89 lines
4.0 KiB
TypeScript
/**
|
|
* FileNode shapes, as Stalwart 0.16 defines them.
|
|
*
|
|
* This used to be a compatibility layer spanning 0.15 and 0.16, which differ
|
|
* in ways the server does not report: `nodeType` did not exist and sending it
|
|
* failed the create outright, `FileNode/query` masked directories out of its
|
|
* own results, and rights were a single `mayWrite` rather than the four
|
|
* 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.
|
|
*/
|
|
import type { FileNode, Id } from "@/jmap/types";
|
|
import { descendantIds } from "./mailbox/folderMove";
|
|
|
|
/** Properties to request for a node. */
|
|
export function fileNodeProps(): string[] {
|
|
return ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "shareWith", "role", "executable", "nodeType"];
|
|
}
|
|
|
|
/** Create-arguments for a directory. */
|
|
export function directoryCreate(parentId: Id | null, name: string): Record<string, unknown> {
|
|
return { parentId, name, nodeType: "directory" };
|
|
}
|
|
|
|
/** Create-arguments for a file with an already-uploaded blob. */
|
|
export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: string): Record<string, unknown> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Whether the node being dragged may be dropped on `targetId`, null being the
|
|
* top level.
|
|
*
|
|
* The same four refusals as folders: onto itself, into its own subtree, onto
|
|
* the parent it already has, or -- for the top level -- when it is already
|
|
* there. `descendantIds` is shared with the mailbox tree, since both are the
|
|
* same shape of tree asking the same question.
|
|
*
|
|
* Rights are deliberately only half-checked. A target that will not take
|
|
* children is refused here, because that is unambiguous. Whether the node may
|
|
* leave the parent it is in is not: JMAP models a move as an update of
|
|
* `parentId` and does not say which right covers it, and guessing would hide
|
|
* legal moves behind a disabled drop. The server refuses those with a message
|
|
* of its own, which is a better answer than a silent one.
|
|
*/
|
|
/** The MIME a dragged node is offered under, so a target can recognize it. */
|
|
export const NODE_MIME = "application/x-ihasmail-filenode";
|
|
|
|
/**
|
|
* The ids in a node drag. A multi-file selection is dragged as one payload, so
|
|
* this is a list even when it holds one -- both drop targets read it the same
|
|
* way and neither has to care how the drag started.
|
|
*/
|
|
export function readDraggedIds(dt: DataTransfer): Id[] {
|
|
return dt.getData(NODE_MIME).split(",").filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* The same question for a multi-file drag. Every one of them has to be able to
|
|
* land, because the drop is one action: allowing a drag that would move four
|
|
* of five files and silently skip the fifth is worse than refusing it.
|
|
*/
|
|
export function canDropFileNodes(nodes: Record<Id, FileNode>, draggedIds: Id[], targetId: Id | null): boolean {
|
|
return draggedIds.length > 0 && draggedIds.every((id) => canDropFileNode(nodes, id, targetId));
|
|
}
|
|
|
|
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
|
const dragged = nodes[draggedId];
|
|
if (!dragged) return false;
|
|
if (targetId === null) return dragged.parentId != null;
|
|
if (targetId === draggedId) return false;
|
|
if (dragged.parentId === targetId) return false;
|
|
const target = nodes[targetId];
|
|
if (!target || target.nodeType !== "directory") return false;
|
|
if (target.myRights && !target.myRights.mayAddChildren) return false;
|
|
return !descendantIds(nodes, draggedId).has(targetId);
|
|
}
|