feat: folder tabs via drag & drop + sidebar in fullscreen email tabs

This commit is contained in:
Linus Rath
2026-08-20 15:06:45 +02:00
parent 485b93f777
commit 3e5ed4f3c4
12 changed files with 760 additions and 54 deletions
+35 -2
View File
@@ -18,10 +18,13 @@ import { ProTabBar } from "@/components/pro/pro-tab-bar";
import {
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
MAILBOX_DRAG_MIME,
dragKindFromTypes,
parseEmailIdsPayload,
parseMailboxDragPayload,
resolveBodyDropTarget,
type BodyDropTarget,
type MailboxDragPayload,
} from "@/components/pro/pro-shell-drop";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProSplitSide } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
@@ -34,6 +37,7 @@ import { FilesApp } from "@/components/files/files-app";
import { SettingsApp } from "@/components/settings/settings-app";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
import { ProFolderTabBody } from "@/components/pro/pro-folder-tab-body";
// Each surface renders the same component the standard routes do. Deep links
// reach them through the handoff ProInterfaceRedirect parks (#733), not as
@@ -53,6 +57,9 @@ function renderTabBody(tab: ProTab): React.ReactNode {
if (tab.kind === 'email' && tab.emailData) {
return <ProEmailTabBody tabId={tab.id} data={tab.emailData} />;
}
if (tab.kind === 'folder' && tab.folderData) {
return <ProFolderTabBody tabId={tab.id} data={tab.folderData} />;
}
const Component = APP_TAB_COMPONENTS[tab.kind];
return Component ? <Component /> : null;
}
@@ -91,6 +98,20 @@ function openDroppedEmails(
}
}
/** Open a dragged sidebar folder as a folder tab in `pane`. */
function openDroppedFolder(
payload: MailboxDragPayload,
pane: ProPaneId,
side: ProSplitSide | undefined,
) {
const { setSplitSide, openFolderTab } = useProTabStore.getState();
if (side) setSplitSide(side);
openFolderTab(
{ accountId: payload.accountId, mailboxId: payload.mailboxId, title: payload.name },
{ pane },
);
}
interface PaneProps {
paneId: ProPaneId;
tabs: ProTab[];
@@ -268,8 +289,8 @@ export default function ProHome() {
splitSide,
kind,
// A tab can only create a split if another tab stays behind; emails
// always can (the mail list keeps existing).
canCreateSplit: kind === 'email' ? true : tabs.length > 1,
// and folders always can (their source surface keeps existing).
canCreateSplit: kind === 'tab' ? tabs.length > 1 : true,
draggedTabPane: kind === 'tab'
? (tabs.find((t) => t.id === draggedTabId)?.paneId ?? null)
: null,
@@ -328,6 +349,17 @@ export default function ProHome() {
return;
}
if (kind === 'folder') {
const payload = parseMailboxDragPayload(e.dataTransfer.getData(MAILBOX_DRAG_MIME));
if (!payload) return;
if (target.type === 'create-split') {
openDroppedFolder(payload, 'split', target.side);
} else {
openDroppedFolder(payload, target.pane, undefined);
}
return;
}
const ids = parseEmailIdsPayload(e.dataTransfer.getData(EMAIL_IDS_DRAG_MIME));
if (ids.length === 0) return;
if (target.type === 'create-split') {
@@ -372,6 +404,7 @@ export default function ProHome() {
onClose={requestCloseTab}
onDragStateChange={(dragging, tabId) => setDraggedTabId(dragging ? tabId : null)}
onEmailDrop={(ids) => openDroppedEmails(ids, paneId, undefined, emailFallbackTitle)}
onFolderDrop={(payload) => openDroppedFolder(payload, paneId, undefined)}
/>
<Pane
paneId={paneId}
+34 -1
View File
@@ -57,6 +57,7 @@ import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { MAILBOX_DRAG_MIME } from "@/components/pro/pro-shell-drop";
import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -476,6 +477,7 @@ function MailboxTreeItem({
onUnreadFilterClick,
colorful,
onContextMenu,
dragAccountId = null,
}: {
node: MailboxNode;
selectedMailbox: string;
@@ -486,6 +488,10 @@ function MailboxTreeItem({
onUnreadFilterClick?: (mailboxId: string) => void;
colorful: boolean;
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
/** Connected-account id this folder is listed under; null = active account.
* Travels in the folder-drag payload so a folder tab fetches through the
* right client. */
dragAccountId?: string | null;
}) {
const tNotifications = useTranslations('notifications');
const tSidebar = useTranslations('sidebar');
@@ -497,6 +503,24 @@ function MailboxTreeItem({
const roleKey = resolveRoleKey(node.role, node.name);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
const isEmbedded = useIsEmbedded();
// Pro shell only: folder rows are drag sources so a folder can be dropped
// onto a tab strip / pane and open as its own tab. Native HTML5 DnD - the
// Pro shell's drop targets live outside any dnd-kit context.
const folderDragHandlers: Record<string, unknown> | undefined =
isEmbedded && !isVirtualNode
? {
draggable: true,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData(
MAILBOX_DRAG_MIME,
JSON.stringify({ mailboxId: node.id, name: label, accountId: dragAccountId }),
);
e.dataTransfer.effectAllowed = 'copy';
},
}
: undefined;
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node,
@@ -538,7 +562,14 @@ function MailboxTreeItem({
onExpandToggle={() => onToggleExpand(node.id)}
onUnreadClick={() => onUnreadFilterClick?.(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
dropHandlers={
folderDragHandlers || globalDragging
? {
...(folderDragHandlers ?? {}),
...(globalDragging ? (dropHandlers as Record<string, unknown>) : {}),
}
: undefined
}
isValidDropTarget={isValidDropTarget}
isInvalidDropTarget={isInvalidDropTarget}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
@@ -556,6 +587,7 @@ function MailboxTreeItem({
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorful}
onContextMenu={onContextMenu}
dragAccountId={dragAccountId}
/>
))}
</>
@@ -1279,6 +1311,7 @@ export function Sidebar({
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
dragAccountId={isActive ? null : account.id}
/>
{isActive && node.role === 'drafts' && renderScheduledRow(`scheduled-${account.id}`)}
</Fragment>
+21
View File
@@ -106,6 +106,7 @@ import {
type MailDeepLink,
} from "@/lib/deep-links";
import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
import { onProMailNavigation } from "@/lib/pro-mail-navigation";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
@@ -2416,6 +2417,26 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
}
};
// Pro email tabs render their own sidebar; clicking a folder/tag there
// publishes a navigation request that this (always-mounted) embedded
// MailApp serves with its own selection handlers. Refs, not deps: the
// handlers close over client/mailboxes and are rebuilt every render.
const proNavHandlersRef = useRef({ handleMailboxSelect, handleAccountMailboxSelect, handleTagSelect });
proNavHandlersRef.current = { handleMailboxSelect, handleAccountMailboxSelect, handleTagSelect };
useEffect(() => {
if (!isEmbedded) return;
return onProMailNavigation((request) => {
const handlers = proNavHandlersRef.current;
if (request.kind === 'tag') {
void handlers.handleTagSelect(request.keywordId);
} else if (request.accountId) {
void handlers.handleAccountMailboxSelect(request.accountId, request.mailboxId);
} else {
void handlers.handleMailboxSelect(request.mailboxId);
}
});
}, [isEmbedded]);
const handleUnreadFilterClick = async (mailboxId: string) => {
const isTogglingOff = selectedMailbox === mailboxId && searchFilters.isUnread === true;
@@ -3,17 +3,21 @@ import {
dragKindFromTypes,
paneOnSide,
parseEmailIdsPayload,
parseMailboxDragPayload,
resolveBodyDropTarget,
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
MAILBOX_DRAG_MIME,
SPLIT_EDGE_FRACTION,
} from '@/components/pro/pro-shell-drop';
describe('dragKindFromTypes', () => {
it('recognises tab and email drags, tab winning when both are present', () => {
it('recognises tab, folder and email drags, tab winning when several are present', () => {
expect(dragKindFromTypes([PRO_TAB_DRAG_MIME])).toBe('tab');
expect(dragKindFromTypes([EMAIL_IDS_DRAG_MIME, 'text/plain'])).toBe('email');
expect(dragKindFromTypes([MAILBOX_DRAG_MIME, 'text/plain'])).toBe('folder');
expect(dragKindFromTypes([EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME])).toBe('tab');
expect(dragKindFromTypes([MAILBOX_DRAG_MIME, PRO_TAB_DRAG_MIME])).toBe('tab');
expect(dragKindFromTypes(['text/plain', 'Files'])).toBeNull();
});
});
@@ -28,6 +32,28 @@ describe('parseEmailIdsPayload', () => {
});
});
describe('parseMailboxDragPayload', () => {
it('parses well-formed payloads', () => {
expect(parseMailboxDragPayload('{"mailboxId":"mb1","name":"Inbox","accountId":null}'))
.toEqual({ mailboxId: 'mb1', name: 'Inbox', accountId: null });
expect(parseMailboxDragPayload('{"mailboxId":"mb1","name":"Inbox","accountId":"acc2"}'))
.toEqual({ mailboxId: 'mb1', name: 'Inbox', accountId: 'acc2' });
});
it('repairs partial payloads and rejects malformed ones', () => {
expect(parseMailboxDragPayload('{"mailboxId":"mb1"}'))
.toEqual({ mailboxId: 'mb1', name: '', accountId: null });
// Empty accountId means "active account", not an account named "".
expect(parseMailboxDragPayload('{"mailboxId":"mb1","name":"X","accountId":""}'))
.toEqual({ mailboxId: 'mb1', name: 'X', accountId: null });
expect(parseMailboxDragPayload('{"name":"no id"}')).toBeNull();
expect(parseMailboxDragPayload('{"mailboxId":""}')).toBeNull();
expect(parseMailboxDragPayload('"just a string"')).toBeNull();
expect(parseMailboxDragPayload('not json')).toBeNull();
expect(parseMailboxDragPayload('null')).toBeNull();
});
});
describe('paneOnSide', () => {
it('maps a visual side to the pane rendered there', () => {
expect(paneOnSide('left', 'right')).toBe('main');
@@ -100,4 +126,12 @@ describe('resolveBodyDropTarget - split active', () => {
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.75, kind: 'email' }))
.not.toBeNull();
});
it('folders behave like emails: droppable on either pane, and on edges when no split', () => {
expect(resolveBodyDropTarget({ ...base, splitSide: 'right', xFrac: 0.25, kind: 'folder' }))
.toEqual({ type: 'pane', pane: 'main', side: 'left' });
expect(resolveBodyDropTarget({
isSplit: false, splitSide: 'right', canCreateSplit: true, xFrac: 0.05, kind: 'folder',
})).toEqual({ type: 'create-split', side: 'left' });
});
});
+158 -28
View File
@@ -3,26 +3,58 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { Sidebar } from "@/components/layout/sidebar";
import { ErrorBoundary, EmailViewerErrorFallback, SidebarErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { findDraftIdentityId } from "@/lib/reply-identity";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import { requestProMailNavigation } from "@/lib/pro-mail-navigation";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import type { Email } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getQuoteBodies } from "@/lib/email-composer-utils";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
import { cn } from "@/lib/utils";
interface ProEmailTabBodyProps {
tabId: string;
data: ProEmailTabData;
}
interface ProEmailViewProps {
emailId: string;
/**
* JMAP client to fetch/mutate through; defaults to the active account's.
* Folder tabs of another connected account pass that account's client.
*/
client?: IJMAPClient | null;
/**
* Owner JMAP accountId for messages in shared/group folders. When set, the
* message is fetched via `getEmail(emailId, accountId)` directly -
* `fetchEmailContent`'s fallback derives the owner from the *globally*
* selected mailbox, which is unrelated to a folder tab's mailbox.
*/
accountId?: string;
/** Fires once the full message is loaded (tab retitling, sidebar highlight). */
onLoaded?: (email: Email) => void;
/**
* The message left this view: deleted, archived, moved, or reopened in the
* composer. The host decides what that means (close the tab / clear the
* folder tab's selection).
*/
onClose: () => void;
className?: string;
}
function buildReplyContext(email: Email): ProReplyContext {
return {
from: email.from,
@@ -42,15 +74,18 @@ function buildReplyContext(email: Email): ProReplyContext {
}
/**
* Renders a single email in its own Pro tab. Fetches the email content on
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient -
* it doesn't depend on what the Mail tab has selected.
* Self-sufficient single-message view for Pro surfaces. Fetches the email on
* mount via `email-store.fetchEmailContent`, so it doesn't depend on what the
* Mail tab has selected; compose actions open Pro compose tabs directly.
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
* reading pane.
*/
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
export function ProEmailView({ emailId, client: clientOverride, accountId, onLoaded, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const client = useAuthStore((s) => s.client);
const activeClient = useAuthStore((s) => s.client);
const client = clientOverride ?? activeClient;
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const markAsRead = useEmailStore((s) => s.markAsRead);
@@ -61,25 +96,26 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const identities = useIdentityStore((s) => s.identities);
const multiAccountIdentities = useProMultiAccountIdentities();
const closeTab = useProTabStore((s) => s.closeTab);
const openComposeTab = useProTabStore((s) => s.openComposeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useState(true);
const composerSessionIdRef = useRef(0);
// Held in a ref so a host passing an inline callback doesn't refetch the
// message on every render.
const onLoadedRef = useRef(onLoaded);
onLoadedRef.current = onLoaded;
useEffect(() => {
let cancelled = false;
if (!client) return;
setIsLoading(true);
fetchEmailContent(client, data.emailId)
(accountId ? client.getEmail(emailId, accountId) : fetchEmailContent(client, emailId))
.then((loaded) => {
if (cancelled) return;
setEmail(loaded);
if (loaded?.subject) {
updateTabTitle(tabId, loaded.subject);
}
if (loaded) onLoadedRef.current?.(loaded);
})
.catch((err) => {
console.error('Failed to fetch email for Pro tab:', err);
@@ -90,7 +126,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
return () => {
cancelled = true;
};
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
}, [client, emailId, accountId, fetchEmailContent]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
@@ -185,12 +221,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
if (!client || !email) return;
try {
await deleteEmail(client, email.id);
closeTab(tabId);
onClose();
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
}, [client, email, deleteEmail, onClose, tNotifications]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
@@ -199,11 +235,11 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
try {
await moveToMailbox(client, email.id, archiveMb.id);
toast.success(tNotifications('email_archived'));
closeTab(tabId);
onClose();
} catch (err) {
console.error('Archive failed:', err);
}
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
}, [client, email, mailboxes, moveToMailbox, onClose, tNotifications]);
const handleToggleStar = useCallback(async () => {
if (!client || !email) return;
@@ -223,11 +259,11 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
}
}, [client, email, toggleStar]);
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
const handleMarkAsRead = useCallback(async (emailIdToMark: string, read: boolean) => {
if (!client) return;
try {
await markAsRead(client, emailId, read);
setEmail((prev) => prev && prev.id === emailId ? {
await markAsRead(client, emailIdToMark, read);
setEmail((prev) => prev && prev.id === emailIdToMark ? {
...prev,
keywords: { ...prev.keywords, $seen: read },
} : prev);
@@ -236,8 +272,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
}
}, [client, markAsRead]);
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
if (!email || email.id !== emailId) return;
const handleSetTag = useCallback((emailIdToTag: string, tagId: string | null) => {
if (!email || email.id !== emailIdToTag) return;
// Toggle one tag, or clear them all. Matches the mail page's local
// optimistic update, down to reaching tags this client cannot name.
const keywords = { ...(email.keywords ?? {}) };
@@ -258,7 +294,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
keywords[KEYWORD_PREFIX + tagId] = true;
}
}
setEmailKeywordsLocal(emailId, keywords);
setEmailKeywordsLocal(emailIdToTag, keywords);
setEmail({ ...email, keywords });
}, [email, setEmailKeywordsLocal]);
@@ -266,11 +302,11 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
if (!client || !email) return;
try {
await moveToMailbox(client, email.id, mailboxId);
closeTab(tabId);
onClose();
} catch (err) {
console.error('Move failed:', err);
}
}, [client, email, moveToMailbox, closeTab, tabId]);
}, [client, email, moveToMailbox, onClose]);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
if (!client) return;
@@ -326,11 +362,11 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
draftId: email.id,
},
});
closeTab(tabId);
}, [email, identities, multiAccountIdentities, openComposeTab, closeTab, tabId, t]);
onClose();
}, [email, identities, multiAccountIdentities, openComposeTab, onClose, t]);
return (
<div className="flex h-full w-full flex-col bg-background">
<div className={cn("flex h-full flex-col bg-background", className)}>
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={email}
@@ -358,3 +394,97 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
</div>
);
}
/**
* Renders a single email in its own Pro tab, Roundcube-style: the folder
* sidebar on the left (wide panes only) with the message filling the rest -
* no list pane. Clicking a folder or tag in that sidebar steers the shared
* Mail tab (via the pro-mail-navigation bus) and focuses it.
*/
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const closeTab = useProTabStore((s) => s.closeTab);
const openTab = useProTabStore((s) => s.openTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const mailboxes = useEmailStore((s) => s.mailboxes);
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
const sidebarWidth = useUIStore((s) => s.sidebarWidth);
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
// Pane-scoped: a narrow split pane drops the sidebar and keeps the mail.
const { isMobile, isTablet } = useDeviceDetection();
const showSidebar = !isMobile && !isTablet;
const [loadedEmail, setLoadedEmail] = useState<Email | null>(null);
const handleLoaded = useCallback((email: Email) => {
setLoadedEmail(email);
if (email.subject) updateTabTitle(tabId, email.subject);
}, [tabId, updateTabTitle]);
const handleClose = useCallback(() => closeTab(tabId), [closeTab, tabId]);
// Messages opened out of a shared/group folder must be fetched with the
// owner's accountId - fetchEmailContent's fallback derives it from the
// globally selected mailbox, which is unrelated to this tab.
const ownerAccountId = useMemo(() => {
if (!data.mailboxId) return undefined;
const mb = mailboxes.find((m) => m.id === data.mailboxId);
return mb?.isShared ? mb.accountId : undefined;
}, [mailboxes, data.mailboxId]);
// Highlight the folder the message lives in; fall back to the mailbox the
// tab was opened from (may be null for drag/drop-opened tabs).
const highlightedMailbox = useMemo(() => {
if (loadedEmail?.mailboxIds) {
const inList = Object.keys(loadedEmail.mailboxIds).find((id) => mailboxes.some((m) => m.id === id));
if (inList) return inList;
}
return data.mailboxId ?? '';
}, [loadedEmail, mailboxes, data.mailboxId]);
const navigateToFolder = useCallback((accountId: string | null, mailboxId: string) => {
openTab('mail');
requestProMailNavigation({ kind: 'mailbox', accountId, mailboxId });
}, [openTab]);
const navigateToTag = useCallback((keywordId: string | null) => {
openTab('mail');
requestProMailNavigation({ kind: 'tag', keywordId });
}, [openTab]);
return (
<div className="flex h-full w-full bg-background">
{showSidebar && (
<div
className="h-full flex-shrink-0 overflow-hidden"
style={{ width: sidebarCollapsed ? 48 : sidebarWidth }}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
{/* The sidebar's folder rows are email-drop targets and read the
drag context unconditionally - give them their own provider
(MailApp's lives in a different tab body). */}
<DragDropProvider>
<Sidebar
mailboxes={mailboxes}
selectedMailbox={highlightedMailbox}
onMailboxSelect={(mailboxId) => navigateToFolder(null, mailboxId)}
onTagSelect={navigateToTag}
multiAccountMode
accountMailboxes={accountMailboxes}
viewingAccountId={null}
onAccountMailboxSelect={navigateToFolder}
/>
</DragDropProvider>
</ErrorBoundary>
</div>
)}
<ProEmailView
emailId={data.emailId}
accountId={ownerAccountId}
onLoaded={handleLoaded}
onClose={handleClose}
className="min-w-0 flex-1"
/>
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Folder, Loader2, Paperclip, RefreshCw, Star } from "lucide-react";
import { cn, formatDate } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { EmailViewer } from "@/components/email/email-viewer";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneId } from "@/hooks/use-pane-context";
import type { Email, Mailbox } from "@/lib/jmap/types";
interface ProFolderTabBodyProps {
tabId: string;
data: ProFolderTabData;
}
/**
* A mailbox opened in its own Pro tab (sidebar folder dragged onto the tab
* strip). Self-sufficient like `ProEmailTabBody`: it queries its folder
* directly through the owning account's JMAP client instead of touching the
* email-store's single global list, so it never fights the Mail tab (or
* another folder tab) over `selectedMailbox`.
*
* Wide panes render list + reading pane; a phone-narrow pane renders just the
* list and opens messages as dedicated email tabs.
*/
export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
const t = useTranslations();
const tList = useTranslations('email_list');
const tSidebar = useTranslations('sidebar');
const activeClient = useAuthStore((s) => s.client);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const activeMailboxes = useEmailStore((s) => s.mailboxes);
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
const emailsPerPage = useSettingsStore((s) => s.emailsPerPage);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const paneId = usePaneId();
// Pane-scoped: in a phone-narrow pane there is no room for a reading pane.
const { isMobile } = useDeviceDetection();
const client = useMemo(
() => (data.accountId ? getClientForAccount(data.accountId) ?? activeClient : activeClient),
[data.accountId, getClientForAccount, activeClient],
);
// The folder's metadata, resolved from whichever account's mailbox list it
// was dragged from. Shared folders are namespaced in the store, so JMAP
// calls need originalId + the owner accountId (mirrors fetchEmails).
const mailbox: Mailbox | undefined = useMemo(() => {
const list = data.accountId ? accountMailboxes[data.accountId] ?? [] : activeMailboxes;
return list.find((m) => m.id === data.mailboxId);
}, [data.accountId, data.mailboxId, accountMailboxes, activeMailboxes]);
const jmapMailboxId = mailbox?.originalId || data.mailboxId;
const jmapAccountId = mailbox?.isShared ? mailbox.accountId : undefined;
const [emails, setEmails] = useState<Email[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null);
const fetchSeqRef = useRef(0);
const folderLabel = mailbox
? localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`))
: data.title;
// Keep the tab title in sync with the folder's (possibly localized) name.
useEffect(() => {
if (folderLabel && folderLabel !== data.title) updateTabTitle(tabId, folderLabel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [folderLabel, tabId, updateTabTitle]);
const loadPage = useCallback(async (position: number) => {
if (!client) return;
const seq = ++fetchSeqRef.current;
if (position === 0) setIsLoading(true); else setIsLoadingMore(true);
// getEmails never throws - it reports failures as an empty page.
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true);
if (seq !== fetchSeqRef.current) return;
setEmails((prev) => {
if (position === 0) return result.emails;
const known = new Set(prev.map((e) => e.id));
return [...prev, ...result.emails.filter((e) => !known.has(e.id))];
});
setTotal(result.total);
setHasMore(result.hasMore);
if (position === 0) setIsLoading(false); else setIsLoadingMore(false);
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage]);
useEffect(() => {
setSelectedEmailId(null);
void loadPage(0);
}, [loadPage]);
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
if (!hasMore || isLoadingMore || isLoading) return;
const el = e.currentTarget;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
void loadPage(emails.length);
}
}, [hasMore, isLoadingMore, isLoading, loadPage, emails.length]);
const markRowAsRead = useCallback((email: Email) => {
if (!client || email.keywords?.$seen) return;
setEmails((prev) => prev.map((e) => e.id === email.id
? { ...e, keywords: { ...e.keywords, $seen: true } }
: e));
// Straight to the client: the store's markAsRead only serves emails in
// its own (global) list, which this tab's rows are not part of.
void client.markAsRead(email.id, true, jmapAccountId).catch((err) => {
console.error('Mark as read failed:', err);
});
}, [client, jmapAccountId]);
const openAsEmailTab = useCallback((email: Email, reuseReader: boolean) => {
useProTabStore.getState().openEmailTab(
{
accountId: email.accountId ?? '',
emailId: email.id,
mailboxId: data.mailboxId,
title: email.subject?.trim() || t('email_composer.new_message'),
},
{ pane: paneId ?? 'main', reuseReader },
);
}, [data.mailboxId, paneId, t]);
const handleRowClick = useCallback((email: Email) => {
markRowAsRead(email);
if (isMobile) {
// No room for a reading pane - promote to a dedicated email tab.
openAsEmailTab(email, true);
return;
}
setSelectedEmailId(email.id);
}, [markRowAsRead, isMobile, openAsEmailTab]);
// The selected message left the folder (deleted / archived / moved /
// reopened as a draft): drop its row and clear the reading pane.
const handleViewerClose = useCallback(() => {
setSelectedEmailId((current) => {
if (current) setEmails((prev) => prev.filter((e) => e.id !== current));
return null;
});
}, []);
// Sent/Drafts show the recipient - the sender is always "me" there.
const showRecipient = mailbox?.role === 'sent' || mailbox?.role === 'drafts';
const list = (
<div className={cn("flex h-full flex-col bg-background", isMobile ? "w-full" : "w-96 flex-shrink-0 border-e border-border")}>
<div className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-border px-3">
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm font-medium">{folderLabel}</span>
{total > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">{total}</span>
)}
<button
onClick={() => void loadPage(0)}
className="ms-auto flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('common.refresh')}
aria-label={t('common.refresh')}
>
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
</button>
</div>
<div className="flex-1 overflow-y-auto" onScroll={handleScroll}>
{isLoading && emails.length === 0 ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : emails.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
{tList('no_emails')}
</div>
) : (
<>
{emails.map((email) => {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged === true;
const counterpart = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const who = counterpart?.name || counterpart?.email || '';
return (
<div
key={email.id}
role="button"
tabIndex={0}
onClick={() => handleRowClick(email)}
onDoubleClick={() => openAsEmailTab(email, false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRowClick(email);
}
}}
className={cn(
"cursor-pointer border-b border-border/50 px-3 py-2 transition-colors",
email.id === selectedEmailId
? "bg-accent text-accent-foreground"
: "hover:bg-muted/50",
)}
>
<div className="flex items-center gap-2">
{isUnread && (
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-primary" aria-hidden="true" />
)}
<span className={cn("min-w-0 flex-1 truncate text-sm", isUnread ? "font-semibold" : "text-foreground/90")}>
{who}
</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(email.receivedAt)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<span className={cn("min-w-0 flex-1 truncate text-sm", isUnread ? "font-medium" : "text-muted-foreground")}>
{email.subject || '—'}
</span>
{email.hasAttachment && (
<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
)}
{isStarred && (
<Star className="h-3.5 w-3.5 flex-shrink-0 fill-yellow-400 text-yellow-400" aria-hidden="true" />
)}
</div>
{email.preview && (
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{email.preview}
</div>
)}
</div>
);
})}
{isLoadingMore && (
<div className="flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{tList('loading_more')}
</div>
)}
</>
)}
</div>
</div>
);
if (isMobile) {
return list;
}
return (
<div className="flex h-full w-full bg-background">
{list}
{selectedEmailId ? (
<ProEmailView
key={selectedEmailId}
emailId={selectedEmailId}
client={client}
accountId={jmapAccountId}
onClose={handleViewerClose}
className="min-w-0 flex-1"
/>
) : (
<EmailViewer email={null} isLoading={false} className="min-w-0 flex-1" />
)}
</div>
);
}
+2 -2
View File
@@ -4,11 +4,11 @@ import { useEffect } from "react";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
import { useProTabStore, type ProAppTabKind } from "@/stores/pro-tab-store";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import { setPendingDeepLink } from "@/lib/deep-link-handoff";
const SURFACE_TO_TAB: Record<AppSurface, Exclude<ProTabKind, 'compose' | 'email'>> = {
const SURFACE_TO_TAB: Record<AppSurface, ProAppTabKind> = {
mail: 'mail',
calendar: 'calendar',
contacts: 'contacts',
+36 -2
View File
@@ -10,19 +10,35 @@ export const PRO_TAB_DRAG_MIME = "application/x-pro-tab-id";
*/
export const EMAIL_IDS_DRAG_MIME = "application/x-email-ids";
/**
* MIME type sidebar folder rows put on the drag (see MailboxTreeItem in
* components/layout/sidebar.tsx). Dropping it on a tab strip or a pane opens
* that folder in its own Pro tab. Payload: JSON `MailboxDragPayload`.
*/
export const MAILBOX_DRAG_MIME = "application/x-mailbox-id";
export interface MailboxDragPayload {
mailboxId: string;
/** Display name at drag time - used as the initial tab title. */
name: string;
/** Connected-account id the folder was dragged from; null = active account. */
accountId: string | null;
}
/** Fraction of the body width on each side that acts as a "create split" edge zone. */
export const SPLIT_EDGE_FRACTION = 0.2;
export type ShellDragKind = "tab" | "email";
export type ShellDragKind = "tab" | "email" | "folder";
export type BodyDropTarget =
| { type: "pane"; pane: ProPaneId; side: ProSplitSide }
| { type: "create-split"; side: ProSplitSide }
| null;
/** Classify a drag by its payload types. Tab drags win over email drags. */
/** Classify a drag by its payload types. Tab drags win over folder/email drags. */
export function dragKindFromTypes(types: readonly string[]): ShellDragKind | null {
if (types.includes(PRO_TAB_DRAG_MIME)) return "tab";
if (types.includes(MAILBOX_DRAG_MIME)) return "folder";
if (types.includes(EMAIL_IDS_DRAG_MIME)) return "email";
return null;
}
@@ -81,3 +97,21 @@ export function parseEmailIdsPayload(raw: string): string[] {
return [];
}
}
/** Parse the mailbox drag payload; returns null for anything malformed. */
export function parseMailboxDragPayload(raw: string): MailboxDragPayload | null {
try {
const parsed = JSON.parse(raw) as Partial<MailboxDragPayload> | null;
if (!parsed || typeof parsed !== "object") return null;
if (typeof parsed.mailboxId !== "string" || parsed.mailboxId.length === 0) return null;
return {
mailboxId: parsed.mailboxId,
name: typeof parsed.name === "string" ? parsed.name : "",
accountId: typeof parsed.accountId === "string" && parsed.accountId.length > 0
? parsed.accountId
: null,
};
} catch {
return null;
}
}
+29 -15
View File
@@ -2,14 +2,17 @@
import { useRef, useState, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, Folder, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import {
PRO_TAB_DRAG_MIME,
EMAIL_IDS_DRAG_MIME,
MAILBOX_DRAG_MIME,
dragKindFromTypes,
parseEmailIdsPayload,
parseMailboxDragPayload,
type MailboxDragPayload,
} from "@/components/pro/pro-shell-drop";
export { PRO_TAB_DRAG_MIME };
@@ -27,6 +30,8 @@ interface ProTabBarProps {
onDragStateChange?: (dragging: boolean, tabId: string | null) => void;
/** Emails dragged from a message list and dropped on this strip. */
onEmailDrop?: (emailIds: string[]) => void;
/** A sidebar folder dragged onto this strip - opens it as a folder tab. */
onFolderDrop?: (payload: MailboxDragPayload) => void;
className?: string;
}
@@ -38,6 +43,7 @@ const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
settings: Settings,
compose: PenSquare,
email: MailOpen,
folder: Folder,
};
type DropIndicator = { targetId: string; edge: "before" | "after" } | null;
@@ -51,13 +57,15 @@ export function ProTabBar({
onClose,
onDragStateChange,
onEmailDrop,
onFolderDrop,
className,
}: ProTabBarProps) {
const tSidebar = useTranslations("sidebar");
const reorderTab = useProTabStore((s) => s.reorderTab);
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
const [isEmailDragOver, setIsEmailDragOver] = useState(false);
// True while an email or folder payload hovers the strip (both drop "anywhere").
const [isPayloadDragOver, setIsPayloadDragOver] = useState(false);
const dragLeaveTimer = useRef<number | null>(null);
const dragKind = (e: DragEvent) => dragKindFromTypes(e.dataTransfer.types);
@@ -70,7 +78,7 @@ export function ProTabBar({
const handleDragEnd = () => {
setDropIndicator(null);
setIsEmailDragOver(false);
setIsPayloadDragOver(false);
onDragStateChange?.(false, null);
};
@@ -78,10 +86,10 @@ export function ProTabBar({
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
// Emails can land anywhere on the strip - no per-tab insertion caret.
if (kind === "email" || kind === "folder") {
// Emails/folders can land anywhere on the strip - no per-tab insertion caret.
e.dataTransfer.dropEffect = "copy";
setIsEmailDragOver(true);
setIsPayloadDragOver(true);
return;
}
e.dataTransfer.dropEffect = "move";
@@ -105,12 +113,18 @@ export function ProTabBar({
if (dragLeaveTimer.current !== null) window.clearTimeout(dragLeaveTimer.current);
dragLeaveTimer.current = window.setTimeout(() => {
setDropIndicator(null);
setIsEmailDragOver(false);
setIsPayloadDragOver(false);
dragLeaveTimer.current = null;
}, 40);
};
const handleEmailDrop = (e: DragEvent<HTMLDivElement>): boolean => {
/** Dropped email/folder payloads - anything that isn't a tab reorder. */
const handlePayloadDrop = (e: DragEvent<HTMLDivElement>): boolean => {
if (e.dataTransfer.types.includes(MAILBOX_DRAG_MIME)) {
const payload = parseMailboxDragPayload(e.dataTransfer.getData(MAILBOX_DRAG_MIME));
if (payload) onFolderDrop?.(payload);
return true;
}
if (!e.dataTransfer.types.includes(EMAIL_IDS_DRAG_MIME)) return false;
const ids = parseEmailIdsPayload(e.dataTransfer.getData(EMAIL_IDS_DRAG_MIME));
if (ids.length > 0) onEmailDrop?.(ids);
@@ -121,8 +135,8 @@ export function ProTabBar({
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
handleEmailDrop(e);
if (kind === "email" || kind === "folder") {
handlePayloadDrop(e);
handleDragEnd();
return;
}
@@ -140,8 +154,8 @@ export function ProTabBar({
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
handleEmailDrop(e);
if (kind === "email" || kind === "folder") {
handlePayloadDrop(e);
handleDragEnd();
return;
}
@@ -161,9 +175,9 @@ export function ProTabBar({
const kind = dragKind(e);
if (!kind) return;
e.preventDefault();
if (kind === "email") {
if (kind === "email" || kind === "folder") {
e.dataTransfer.dropEffect = "copy";
setIsEmailDragOver(true);
setIsPayloadDragOver(true);
return;
}
e.dataTransfer.dropEffect = "move";
@@ -177,7 +191,7 @@ export function ProTabBar({
<div
className={cn(
"relative flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
isEmailDragOver && "bg-primary/10",
isPayloadDragOver && "bg-primary/10",
className,
)}
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
+40
View File
@@ -0,0 +1,40 @@
/**
* Navigation requests into the Pro shell's Mail tab from surfaces outside it.
*
* A Pro email tab renders its own folder sidebar (Roundcube-style mail view);
* clicking a folder there must steer the *Mail tab's* list. The Mail tab's
* `MailApp` owns all the folder-selection logic (unified views, scheduled,
* cross-account, keeping searches applied, ...), so instead of duplicating
* it, the email tab focuses the Mail tab and publishes a request here that
* the embedded MailApp handles with its own selection handlers.
*
* The deep-link handoff (lib/deep-link-handoff.ts) can't serve this: it is
* consumed once on mount, and in the Pro shell the Mail tab's MailApp stays
* mounted for the whole session.
*/
export type ProMailNavigationRequest =
| {
kind: 'mailbox';
/** Connected-account id owning the folder; null = the active account. */
accountId: string | null;
mailboxId: string;
}
| { kind: 'tag'; keywordId: string | null };
type Listener = (request: ProMailNavigationRequest) => void;
const listeners = new Set<Listener>();
export function onProMailNavigation(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Publish a request. Returns false when no Mail tab is listening. */
export function requestProMailNavigation(request: ProMailNavigationRequest): boolean {
if (listeners.size === 0) return false;
for (const listener of [...listeners]) listener(request);
return true;
}
+48
View File
@@ -197,6 +197,54 @@ describe('openEmailTab', () => {
});
});
describe('openFolderTab', () => {
const data = (n: number, accountId: string | null = null) => ({
accountId,
mailboxId: `mb-${n}`,
title: `Folder ${n}`,
});
it('opens in the focused pane by default and activates the tab', () => {
const id = useProTabStore.getState().openFolderTab(data(1));
const s = useProTabStore.getState();
const tab = s.tabs.find((t) => t.id === id)!;
expect(tab.kind).toBe('folder');
expect(tab.folderData?.mailboxId).toBe('mb-1');
expect(tab.paneId).toBe('main');
expect(s.activeTabId).toBe(id);
assertInvariants();
});
it('focuses an already-open folder instead of opening it twice', () => {
const first = useProTabStore.getState().openFolderTab(data(2), { pane: 'split' });
useProTabStore.getState().setFocusedPane('main');
const second = useProTabStore.getState().openFolderTab(data(2));
const s = useProTabStore.getState();
expect(second).toBe(first);
expect(s.tabs.filter((t) => t.kind === 'folder')).toHaveLength(1);
expect(s.activeSplitTabId).toBe(first);
expect(s.focusedPaneId).toBe('split');
assertInvariants();
});
it('the same mailbox id under different accounts opens separate tabs', () => {
const a = useProTabStore.getState().openFolderTab(data(3, null));
const b = useProTabStore.getState().openFolderTab(data(3, 'acc-2'));
expect(b).not.toBe(a);
expect(useProTabStore.getState().tabs.filter((t) => t.kind === 'folder')).toHaveLength(2);
assertInvariants();
});
it("pane: 'split' with no split creates one", () => {
const id = useProTabStore.getState().openFolderTab(data(4), { pane: 'split' });
const s = useProTabStore.getState();
expect(s.tabs.find((t) => t.id === id)?.paneId).toBe('split');
expect(s.splitOrientation).toBe('vertical');
expect(s.activeSplitTabId).toBe(id);
assertInvariants();
});
});
describe('closeTab', () => {
it('activates a neighbour when the active tab closes', () => {
seed({ tabs: [homeTab(), appTab('a', 'calendar'), appTab('b', 'contacts')], activeTabId: 'a' });
+48 -3
View File
@@ -4,9 +4,9 @@ import type { ComposerDraftData } from '@/components/email/email-composer';
export type ProTabKind =
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
| 'compose' | 'email';
| 'compose' | 'email' | 'folder';
export type ProAppTabKind = Exclude<ProTabKind, 'compose' | 'email'>;
export type ProAppTabKind = Exclude<ProTabKind, 'compose' | 'email' | 'folder'>;
export type ProPaneId = 'main' | 'split';
@@ -66,15 +66,27 @@ export interface ProEmailTabData {
title: string;
}
export interface ProFolderTabData {
/**
* Connected-account id the folder belongs to (multi-account sidebar
* groups); null = the active account. Used to resolve the JMAP client the
* folder tab fetches through.
*/
accountId: string | null;
mailboxId: string;
title: string;
}
export interface ProTab {
id: string;
kind: ProTabKind;
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email/folder. */
labelKey: string;
title?: string;
closeable: boolean;
composeData?: ProComposeTabData;
emailData?: ProEmailTabData;
folderData?: ProFolderTabData;
/** Which pane this tab lives in. Defaults to 'main' for the single-pane case. */
paneId: ProPaneId;
}
@@ -117,6 +129,12 @@ interface ProTabState extends ProTabCoreState {
openTab: (kind: ProAppTabKind) => string;
openComposeTab: (data: ProComposeTabData) => string;
openEmailTab: (data: ProEmailTabData, opts?: OpenEmailTabOptions) => string;
/**
* Open a mailbox in its own tab (sidebar folder dragged onto a tab strip
* or a pane). One tab per folder: opening an already-open folder focuses
* its tab instead of duplicating it.
*/
openFolderTab: (data: ProFolderTabData, opts?: { pane?: ProPaneId }) => string;
closeTab: (id: string) => void;
/**
* Request closing a tab, honouring any registered close interceptor (e.g. a
@@ -419,6 +437,33 @@ export const useProTabStore = create<ProTabState>()(
return newTab.id;
},
openFolderTab: (data, opts) => {
const state = core(get());
// Same folder already open anywhere: focus it, never open twice.
const existing = state.tabs.find(
(tab) => tab.kind === 'folder'
&& tab.folderData?.mailboxId === data.mailboxId
&& (tab.folderData?.accountId ?? null) === (data.accountId ?? null)
);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind: 'folder',
labelKey: '',
title: data.title,
closeable: true,
folderData: data,
paneId: opts?.pane ?? state.focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
requestCloseTab: (id) => {
const interceptor = closeInterceptors.get(id);
if (interceptor) {