diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 732528ac2..d663b7d10 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -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 ; } + if (tab.kind === 'folder' && tab.folderData) { + return ; + } const Component = APP_TAB_COMPONENTS[tab.kind]; return 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)} /> 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 | 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) : undefined} + dropHandlers={ + folderDragHandlers || globalDragging + ? { + ...(folderDragHandlers ?? {}), + ...(globalDragging ? (dropHandlers as Record) : {}), + } + : 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}`)} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 250f19e15..42a37c134 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; diff --git a/components/pro/__tests__/pro-shell-drop.test.ts b/components/pro/__tests__/pro-shell-drop.test.ts index b30a55494..ec35e9eb5 100644 --- a/components/pro/__tests__/pro-shell-drop.test.ts +++ b/components/pro/__tests__/pro-shell-drop.test.ts @@ -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' }); + }); }); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index b37a5abef..a7e8826e2 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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(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 ( -
+
); } + +/** + * 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(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 ( +
+ {showSidebar && ( +
+ + {/* 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). */} + + navigateToFolder(null, mailboxId)} + onTagSelect={navigateToTag} + multiAccountMode + accountMailboxes={accountMailboxes} + viewingAccountId={null} + onAccountMailboxSelect={navigateToFolder} + /> + + +
+ )} + +
+ ); +} diff --git a/components/pro/pro-folder-tab-body.tsx b/components/pro/pro-folder-tab-body.tsx new file mode 100644 index 000000000..248dbe609 --- /dev/null +++ b/components/pro/pro-folder-tab-body.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [selectedEmailId, setSelectedEmailId] = useState(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) => { + 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 = ( +
+
+ + {folderLabel} + {total > 0 && ( + {total} + )} + +
+ +
+ {isLoading && emails.length === 0 ? ( +
+ +
+ ) : emails.length === 0 ? ( +
+ {tList('no_emails')} +
+ ) : ( + <> + {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 ( +
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", + )} + > +
+ {isUnread && ( +
+
+ + {email.subject || '—'} + + {email.hasAttachment && ( +
+ {email.preview && ( +
+ {email.preview} +
+ )} +
+ ); + })} + {isLoadingMore && ( +
+ + {tList('loading_more')} +
+ )} + + )} +
+
+ ); + + if (isMobile) { + return list; + } + + return ( +
+ {list} + {selectedEmailId ? ( + + ) : ( + + )} +
+ ); +} diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index ae353afd1..5152656e4 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -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> = { +const SURFACE_TO_TAB: Record = { mail: 'mail', calendar: 'calendar', contacts: 'contacts', diff --git a/components/pro/pro-shell-drop.ts b/components/pro/pro-shell-drop.ts index e124a180d..c97741e09 100644 --- a/components/pro/pro-shell-drop.ts +++ b/components/pro/pro-shell-drop.ts @@ -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 | 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; + } +} diff --git a/components/pro/pro-tab-bar.tsx b/components/pro/pro-tab-bar.tsx index d1212360d..4d851ba78 100644 --- a/components/pro/pro-tab-bar.tsx +++ b/components/pro/pro-tab-bar.tsx @@ -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 = { 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(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(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): boolean => { + /** Dropped email/folder payloads - anything that isn't a tab reorder. */ + const handlePayloadDrop = (e: DragEvent): 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({
void; + +const listeners = new Set(); + +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; +} diff --git a/stores/__tests__/pro-tab-store.test.ts b/stores/__tests__/pro-tab-store.test.ts index ded642164..2fe352e1c 100644 --- a/stores/__tests__/pro-tab-store.test.ts +++ b/stores/__tests__/pro-tab-store.test.ts @@ -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' }); diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index a723a3f6a..b77e482bd 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -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; +export type ProAppTabKind = Exclude; 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()( 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) {