mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: folder tabs via drag & drop + sidebar in fullscreen email tabs
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user