Files
webmail/hooks/use-mailbox-drop.ts
T

290 lines
12 KiB
TypeScript

"use client";
import { useCallback, useState, DragEvent } from "react";
import { Mailbox, Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useShallow } from "zustand/react/shallow";
import { emailKeyFor } from "@/lib/thread-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { dragDropHooks } from "@/lib/plugin-hooks";
import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
/**
* Returns the source accountId for an email being dragged. In unified view
* each email carries its own `accountId`; otherwise everything in the view
* belongs to whichever account is currently being viewed (Pro shell's
* Thunderbird-style sidebar) or the globally-active account.
*/
function resolveSourceAccountId(email: Email | undefined): string | null {
if (email?.accountId) return email.accountId;
const viewingId = useEmailStore.getState().viewingAccountId;
if (viewingId) return viewingId;
return useAuthStore.getState().activeAccountId;
}
/**
* Returns the local accountId ("user@host") that owns the destination
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
* namespace. We map back by matching the JMAP id against each connected
* client's `getAccountId()`. Falls back to the viewing/active account so
* single-account flows (no connected clients map entry yet, in-memory edits,
* etc.) still resolve correctly.
*/
function resolveDestAccountId(mailbox: Mailbox): string | null {
const jmapId = mailbox.accountId;
if (jmapId) {
const clients = useAuthStore.getState().getAllConnectedClients();
for (const [localId, client] of clients) {
if (client.getAccountId() === jmapId) return localId;
}
}
return useEmailStore.getState().viewingAccountId
?? useAuthStore.getState().activeAccountId;
}
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
// Translation callbacks for toast messages
onSuccess?: (count: number, mailboxName: string) => void;
onError?: (error: string) => void;
}
interface UseMailboxDropReturn {
dropHandlers: {
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
};
isDropTarget: boolean;
isValidDropTarget: boolean;
isInvalidDropTarget: boolean;
}
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const { moveEmailsToMailbox, moveToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(useShallow((state) => ({
moveEmailsToMailbox: state.moveEmailsToMailbox,
moveToMailbox: state.moveToMailbox,
crossAccountMoveEmails: state.crossAccountMoveEmails,
selectedEmailKeys: state.selectedEmailKeys,
clearSelection: state.clearSelection,
refreshCurrentMailbox: state.refreshCurrentMailbox,
mailboxes: state.mailboxes,
})));
const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext();
// Determine if this is a valid drop target
const isValidTarget = useCallback(() => {
if (!isDragging) return false;
// Cannot drop on same mailbox
if (mailbox.id === sourceMailboxId) return false;
// Check if mailbox accepts items
if (!mailbox.myRights?.mayAddItems) return false;
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
return true;
}, [isDragging, mailbox, sourceMailboxId]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (isValidTarget()) {
e.dataTransfer.dropEffect = "move";
} else {
e.dataTransfer.dropEffect = "none";
}
}, [isValidTarget]);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// Only leave if actually leaving the element (not entering a child)
const relatedTarget = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(relatedTarget)) {
setIsOver(false);
}
}, []);
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(false);
if (!client || !isValidTarget()) {
endDrag();
return;
}
try {
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
if (!emailIdsJson) {
endDrag();
return;
}
const emailIds: string[] = JSON.parse(emailIdsJson);
markDropped();
// Group dragged emails by source account. In single-account flows this
// collapses to one bucket; in unified view or the Pro multi-account
// sidebar a single drag can mix sources.
const destAccountId = resolveDestAccountId(mailbox);
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
const bySource = new Map<string, string[]>();
for (const id of emailIds) {
const srcAccountId = resolveSourceAccountId(idToEmail.get(id));
if (!srcAccountId) continue;
if (!bySource.has(srcAccountId)) bySource.set(srcAccountId, []);
bySource.get(srcAccountId)!.push(id);
}
// Decide whether to route via the cross-account (blob copy + import)
// pipeline. Two cases require it:
// 1. Destination is a delegated/shared mailbox whose owner JMAP
// account differs from the source mailbox's JMAP account. There's
// no atomic Email/set across accounts, even via the same client.
// 2. Destination is a primary mailbox on a different connected local
// account than the source — the historical multi-account case.
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
const sourceJmapAccountId = sourceMb?.accountId;
const destJmapAccountId = mailbox.accountId;
const sourceAccountIds = Array.from(bySource.keys());
const isJmapCrossAccount =
!!sourceJmapAccountId &&
!!destJmapAccountId &&
sourceJmapAccountId !== destJmapAccountId;
const isLocalCrossAccount =
!mailbox.isShared &&
!!destAccountId &&
sourceAccountIds.some((src) => src !== destAccountId);
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
// Aggregate views (unified, cross-account search) have no real source
// folder; each email is stamped with the JMAP account it lives in, and
// bare ids repeat across accounts. Route every dragged email by its own
// stamp: a bare-id move resolves the first namesake in the list, which
// can belong to another account. (#857)
const stamped = draggedEmails.filter((em) => emailIds.includes(em.id) && em.sourceAccountId && em.sourceClientAccountId);
const routeByStamp = !sourceMb && !!destJmapAccountId && stamped.length > 0;
let moved: boolean;
if (routeByStamp) {
moved = true;
const elsewhere = new Map<string, Email[]>();
for (const em of stamped) {
if (em.sourceAccountId === destJmapAccountId) {
moved = (await moveToMailbox(client, em.id, mailbox.id, emailKeyFor(em))) && moved;
} else {
if (!elsewhere.has(em.sourceAccountId!)) elsewhere.set(em.sourceAccountId!, []);
elsewhere.get(em.sourceAccountId!)!.push(em);
}
}
if (elsewhere.size > 0) {
if (!destAccountId) throw new Error('Could not resolve destination account');
const jmapDestId = mailbox.originalId || mailbox.id;
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
for (const [sourceJmapId, group] of elsewhere) {
const ids = new Map([[group[0].sourceClientAccountId!, group.map((em) => em.id)]]);
moved = (await crossAccountMoveEmails(ids, destAccountId, jmapDestId, destJmapOverride, sourceJmapId)) && moved;
}
}
} else if (isCrossAccount) {
if (!destAccountId) {
throw new Error('Could not resolve destination account');
}
// For a shared destination there is no separately-connected client
// for the owner; we reuse the viewing user's client but tell the
// import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id;
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
// Mirror image for the source: a shared group mailbox is accessed
// through the viewing user's client, but the email/blob live in the
// owner's JMAP account, so the copy/delete must target it.
const sourceJmapOverride = sourceMb?.isShared ? sourceMb.accountId : undefined;
moved = await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride, sourceJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
moved = await moveEmailsToMailbox(client, emailIds, mailbox.id);
}
// Nothing moved: a plugin stopped it (and said why), so no drop
// event and no "moved" message.
if (!moved) {
onDropComplete?.();
return;
}
void dragDropHooks.onEmailDrop.emit({ emailIds, targetMailboxId: mailbox.id });
// Clear selection if any selected emails were moved. The selection is
// keyed by owning account, so compare the dragged emails, not their ids.
if (draggedEmails.some(em => selectedEmailKeys.has(emailKeyFor(em)))) {
clearSelection();
}
// Refresh the current mailbox view (honors active search/filters).
// Skip for cross-account moves: the store already dropped the moved
// rows from the in-memory list and refreshed both accounts' folder
// caches in the background.
if (!isCrossAccount) {
await refreshCurrentMailbox(client);
}
const mailboxPath = getMailboxPath(mailbox, mailboxes);
if (onSuccess) {
onSuccess(emailIds.length, mailboxPath);
} else {
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailboxPath}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailboxPath}`);
}
}
onDropComplete?.();
} catch (error) {
console.error("Failed to move emails:", error);
// Call error callback if provided, otherwise use fallback
if (onError) {
onError(error instanceof Error ? error.message : 'Unknown error');
} else {
// Fallback for backward compatibility
toast.error("Move failed", "Could not move emails to the selected folder");
}
} finally {
endDrag();
}
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, moveToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailKeys, clearSelection, refreshCurrentMailbox, endDrag, markDropped, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
return {
dropHandlers: {
onDragOver: handleDragOver,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
isDropTarget: isOver && isDragging,
isValidDropTarget: isOver && valid,
isInvalidDropTarget: isOver && isDragging && !valid,
};
}