mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: show shared mailboxes under each account when several are signed in #1486
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// #1486: with two logins the Pro sidebar shows one folder group per login,
|
||||
// and the shared mailboxes were left out of it altogether. Each group now has
|
||||
// its own Shared section with the mailboxes that login can reach.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { useUIStore } = await import('@/stores/ui-store');
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
|
||||
const own = (id: string, name: string, role: string | null) =>
|
||||
({ id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, sortOrder: 0, myRights: { mayAddItems: true } }) as unknown as Mailbox;
|
||||
const shared = (accountId: string, accountName: string) =>
|
||||
({
|
||||
id: `${accountId}-inbox`, name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0,
|
||||
sortOrder: 0, myRights: { mayAddItems: true }, accountId, accountName, isShared: true,
|
||||
}) as unknown as Mailbox;
|
||||
|
||||
const account = (id: string) => ({
|
||||
id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`,
|
||||
cookieSlot: id === 'a' ? 0 : 1, isConnected: true,
|
||||
});
|
||||
|
||||
// Login a is active and has no shared mailbox; login b has one.
|
||||
const activeMailboxes = [own('a-inbox', 'Inbox', 'inbox')];
|
||||
const otherOwn = { b: [own('b-inbox', 'Inbox', 'inbox')] };
|
||||
const otherShared = { b: [shared('team', 'Team mailbox')] };
|
||||
|
||||
const sharedHeaders = () => screen.queryAllByTestId('section-shared-account').map((el) => el.textContent);
|
||||
|
||||
describe('Pro sidebar shared mailboxes with several logins (#1486)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useUIStore.setState({ sidebarCollapsed: false } as never);
|
||||
useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'a' } as never);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [],
|
||||
enableUnifiedMailbox: false,
|
||||
hideAccountSwitcher: true,
|
||||
showSharedMailAccounts: true,
|
||||
hiddenSharedMailAccounts: {},
|
||||
compactSharedMailboxes: false,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('lists the shared mailboxes of a login that is not the active one', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
mailboxes={activeMailboxes}
|
||||
selectedMailbox="a-inbox"
|
||||
multiAccountMode
|
||||
accountMailboxes={otherOwn}
|
||||
accountSharedMailboxes={otherShared}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId('section-shared-in-account')).toHaveLength(1);
|
||||
expect(sharedHeaders()).toEqual([expect.stringContaining('Team mailbox')]);
|
||||
});
|
||||
|
||||
it('lists the active login\'s shared mailboxes inside its group', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'b' } as never);
|
||||
render(
|
||||
<Sidebar
|
||||
mailboxes={[own('b-inbox', 'Inbox', 'inbox'), shared('team', 'Team mailbox')]}
|
||||
selectedMailbox="b-inbox"
|
||||
multiAccountMode
|
||||
accountMailboxes={{ a: activeMailboxes }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(sharedHeaders()).toEqual([expect.stringContaining('Team mailbox')]);
|
||||
});
|
||||
|
||||
it('opens another login\'s shared mailbox through that login', () => {
|
||||
const onLoginSharedMailboxSelect = vi.fn();
|
||||
const onAccountMailboxSelect = vi.fn();
|
||||
render(
|
||||
<Sidebar
|
||||
mailboxes={activeMailboxes}
|
||||
selectedMailbox="a-inbox"
|
||||
multiAccountMode
|
||||
accountMailboxes={otherOwn}
|
||||
accountSharedMailboxes={otherShared}
|
||||
onAccountMailboxSelect={onAccountMailboxSelect}
|
||||
onLoginSharedMailboxSelect={onLoginSharedMailboxSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const section = screen.getByTestId('section-shared-in-account');
|
||||
fireEvent.click(within(section).getByText('Team mailbox'));
|
||||
fireEvent.click(section.querySelector('[data-mailbox-id="team-inbox"] > button')!);
|
||||
|
||||
expect(onLoginSharedMailboxSelect).toHaveBeenCalledWith('b', 'team-inbox');
|
||||
expect(onAccountMailboxSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the active login\'s shared mailbox like its other folders', () => {
|
||||
useAuthStore.setState({ activeAccountId: 'b' } as never);
|
||||
const onAccountMailboxSelect = vi.fn();
|
||||
render(
|
||||
<Sidebar
|
||||
mailboxes={[own('b-inbox', 'Inbox', 'inbox'), shared('team', 'Team mailbox')]}
|
||||
selectedMailbox="b-inbox"
|
||||
multiAccountMode
|
||||
accountMailboxes={{ a: activeMailboxes }}
|
||||
onAccountMailboxSelect={onAccountMailboxSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const section = screen.getByTestId('section-shared-in-account');
|
||||
fireEvent.click(within(section).getByText('Team mailbox'));
|
||||
fireEvent.click(section.querySelector('[data-mailbox-id="team-inbox"] > button')!);
|
||||
|
||||
expect(onAccountMailboxSelect).toHaveBeenCalledWith(null, 'team-inbox');
|
||||
});
|
||||
|
||||
it('hides a shared account hidden for that login only', () => {
|
||||
useSettingsStore.setState({ hiddenSharedMailAccounts: { b: ['team'] } } as never);
|
||||
render(
|
||||
<Sidebar
|
||||
mailboxes={activeMailboxes}
|
||||
selectedMailbox="a-inbox"
|
||||
multiAccountMode
|
||||
accountMailboxes={otherOwn}
|
||||
accountSharedMailboxes={otherShared}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('section-shared-in-account')).toBeNull();
|
||||
});
|
||||
});
|
||||
+190
-122
@@ -160,6 +160,12 @@ interface SidebarProps {
|
||||
accountMailboxes?: Record<string, Mailbox[]>;
|
||||
viewingAccountId?: string | null;
|
||||
onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void;
|
||||
/** Shared mailboxes of each non-active login, keyed by login (#1486). */
|
||||
accountSharedMailboxes?: Record<string, Mailbox[]>;
|
||||
/** A shared mailbox of a non-active login was picked: the caller switches
|
||||
* to that login and opens it, since shared mail is reached through the
|
||||
* active login. */
|
||||
onLoginSharedMailboxSelect?: (loginId: string, mailboxId: string) => void;
|
||||
}
|
||||
|
||||
const ROW_PX_BASE = 8;
|
||||
@@ -557,6 +563,8 @@ function SidebarRow({
|
||||
const isSystemFolder = (node: MailboxNode) => !!node.role;
|
||||
/** Key of the single-account "Your folders" section in the collapsed set. */
|
||||
const OWN_FOLDERS_KEY = '__own__';
|
||||
/** Prefix of a Pro account group's "Shared" section in the same set. */
|
||||
const SHARED_KEY_PREFIX = '__shared__:';
|
||||
|
||||
function SidebarSectionHeader({
|
||||
label,
|
||||
@@ -677,6 +685,7 @@ function MailboxTreeItem({
|
||||
colorful,
|
||||
onContextMenu,
|
||||
dragAccountId = null,
|
||||
pickOnly = false,
|
||||
sharedAccountName,
|
||||
renderAfterChild,
|
||||
}: {
|
||||
@@ -693,6 +702,9 @@ function MailboxTreeItem({
|
||||
* Travels in the folder-drag payload so a folder tab fetches through the
|
||||
* right client, and tells a message drop which login the folder is in. */
|
||||
dragAccountId?: string | null;
|
||||
/** Listed for opening only: no drag, no message drop, no mute state. For
|
||||
* another login's shared mailboxes, which the active login can't reach. */
|
||||
pickOnly?: boolean;
|
||||
/** Set when this node is a shared account's inbox standing for the whole
|
||||
* account (compact shared mailboxes, #1342): the row is named after the
|
||||
* account and carries its icon. */
|
||||
@@ -711,14 +723,14 @@ function MailboxTreeItem({
|
||||
const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
// Only for the active login's folders: its client tells a missing
|
||||
// accountId (own mailbox) apart.
|
||||
const muted = useMutedMailbox(isVirtualNode || dragAccountId ? null : node);
|
||||
const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node);
|
||||
|
||||
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
|
||||
isEmbedded && !isVirtualNode && !pickOnly
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e: React.DragEvent) => {
|
||||
@@ -768,15 +780,15 @@ function MailboxTreeItem({
|
||||
onUnreadClick={() => onUnreadFilterClick?.(node.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={
|
||||
folderDragHandlers || globalDragging
|
||||
folderDragHandlers || (globalDragging && !pickOnly)
|
||||
? {
|
||||
...(folderDragHandlers ?? {}),
|
||||
...(globalDragging ? (dropHandlers as Record<string, unknown>) : {}),
|
||||
...(globalDragging && !pickOnly ? (dropHandlers as Record<string, unknown>) : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
isInvalidDropTarget={isInvalidDropTarget}
|
||||
isValidDropTarget={isValidDropTarget && !pickOnly}
|
||||
isInvalidDropTarget={isInvalidDropTarget && !pickOnly}
|
||||
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||
muted={muted}
|
||||
/>
|
||||
@@ -794,6 +806,7 @@ function MailboxTreeItem({
|
||||
colorful={colorful}
|
||||
onContextMenu={onContextMenu}
|
||||
dragAccountId={dragAccountId}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
{renderAfterChild?.(child)}
|
||||
</Fragment>
|
||||
@@ -1129,6 +1142,8 @@ function SidebarImpl({
|
||||
accountMailboxes,
|
||||
viewingAccountId = null,
|
||||
onAccountMailboxSelect,
|
||||
accountSharedMailboxes,
|
||||
onLoginSharedMailboxSelect,
|
||||
mvShell = false,
|
||||
}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
@@ -1395,7 +1410,15 @@ function SidebarImpl({
|
||||
const tree = buildMailboxTree(accountMailboxList).filter(
|
||||
(n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
// The active login's shared mailboxes are in `mailboxes`; the others'
|
||||
// are kept beside their own folders (#1486).
|
||||
const hiddenIds = hiddenSharedMailIds(hiddenSharedMailAccounts, account.id);
|
||||
const shared = isActive
|
||||
? sharedAccounts
|
||||
: buildMailboxTree(accountSharedMailboxes?.[account.id] ?? []).filter((n) =>
|
||||
n.id.startsWith('shared-account-')
|
||||
&& isSharedMailAccountVisible(n.accountId, showSharedMailAccounts, hiddenIds));
|
||||
return { account, isActive, tree, shared };
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -1483,6 +1506,148 @@ function SidebarImpl({
|
||||
);
|
||||
};
|
||||
|
||||
// The Shared section: the group and delegated mailboxes a login can reach.
|
||||
// With several logins in the Pro sidebar each one gets its own, inside its
|
||||
// group (#1486). Only the active login's has menus, its Scheduled rows
|
||||
// and its shared tags, as with the folders of the other logins.
|
||||
const renderSharedSection = (
|
||||
accounts: MailboxNode[],
|
||||
{
|
||||
sub = false,
|
||||
expanded,
|
||||
onToggle,
|
||||
selected,
|
||||
onSelect,
|
||||
active = true,
|
||||
}: {
|
||||
sub?: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
selected: string;
|
||||
onSelect?: (mailboxId: string) => void;
|
||||
active?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (accounts.length === 0) return null;
|
||||
return (
|
||||
<div data-testid={sub ? "section-shared-in-account" : undefined}>
|
||||
<SidebarSectionHeader
|
||||
label={t("shared")}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
isCollapsed={isCollapsed}
|
||||
sub={sub}
|
||||
testId="section-shared"
|
||||
/>
|
||||
{((expanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{accounts.map((account) => {
|
||||
const accountExpanded = expandedSharedAccounts.has(account.id);
|
||||
const menuAccountId = sharedAccountMenuId(account.accountId);
|
||||
const compact = compactSharedMailboxes ? compactSharedMailbox(account) : null;
|
||||
if (compact) {
|
||||
// A row without folders below it has no chevron, so what
|
||||
// else hangs below it (Scheduled, tags) stays shown.
|
||||
const compactExpanded = !isCollapsed
|
||||
&& (expandedFolders.has(compact.id) || compact.children.length === 0);
|
||||
const scheduledCount = active && menuAccountId ? (scheduledTotalByAccount?.[menuAccountId] ?? 0) : 0;
|
||||
const scheduledRow = () => scheduledCount > 0
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId, depth: 1 });
|
||||
// Scheduled goes after Drafts, as under a header row; with
|
||||
// Drafts left out (empty), after the other folders.
|
||||
const hasDrafts = compact.children.some((child) => child.role === 'drafts');
|
||||
// "Mark subfolders read" covers the inbox's own subfolders
|
||||
// only, not the account's folders listed below the row.
|
||||
const inboxHasSubfolders = (account.children.find((child) => child.id === compact.id)?.children.length ?? 0) > 0;
|
||||
return (
|
||||
<div key={account.id} data-testid="compact-shared-mailbox">
|
||||
<MailboxTreeItem
|
||||
node={compact}
|
||||
sharedAccountName={account.name}
|
||||
selectedMailbox={selected}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={active ? (e, node) => handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders) : undefined}
|
||||
pickOnly={!active}
|
||||
renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()}
|
||||
/>
|
||||
{compactExpanded && !hasDrafts && scheduledRow()}
|
||||
{active && compactExpanded && menuAccountId && activeAccountId && (
|
||||
<SharedAccountTags
|
||||
loginId={activeAccountId}
|
||||
accountId={menuAccountId}
|
||||
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
|
||||
expandedTags={expandedTags}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={account.id}>
|
||||
<SidebarSectionHeader
|
||||
label={account.name}
|
||||
expanded={accountExpanded}
|
||||
onToggle={() => toggleSharedAccount(account.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
onContextMenu={active && menuAccountId
|
||||
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
|
||||
: undefined}
|
||||
/>
|
||||
{accountExpanded && !isCollapsed && (
|
||||
<>
|
||||
{account.children.map((child) => (
|
||||
<Fragment key={child.id}>
|
||||
<MailboxTreeItem
|
||||
node={child}
|
||||
selectedMailbox={selected}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={active ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={active ? handleMailboxContextMenu : undefined}
|
||||
pickOnly={!active}
|
||||
/>
|
||||
{active && child.role === 'drafts' && menuAccountId
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
||||
</Fragment>
|
||||
))}
|
||||
{active && menuAccountId && !account.children.some((child) => child.role === 'drafts')
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
||||
{active && menuAccountId && activeAccountId && (
|
||||
<SharedAccountTags
|
||||
loginId={activeAccountId}
|
||||
accountId={menuAccountId}
|
||||
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
|
||||
expandedTags={expandedTags}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// The Tags section. It follows the shared mailboxes unless the admin policy
|
||||
// lists it above them, so a long list of shared mailboxes does not push the
|
||||
// tags out of sight.
|
||||
@@ -1871,7 +2036,7 @@ function SidebarImpl({
|
||||
)}
|
||||
|
||||
{useMultiAccount ? (
|
||||
accountGroups.map(({ account, isActive, tree }) => {
|
||||
accountGroups.map(({ account, isActive, tree, shared }) => {
|
||||
const expanded = !collapsedAccountGroups.has(account.id);
|
||||
const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id;
|
||||
return (
|
||||
@@ -1921,6 +2086,17 @@ function SidebarImpl({
|
||||
active: isActive,
|
||||
dragAccountId: isActive ? null : account.id,
|
||||
})}
|
||||
{renderSharedSection(shared, {
|
||||
sub: true,
|
||||
expanded: !collapsedCustomFolders.has(`${SHARED_KEY_PREFIX}${account.id}`),
|
||||
onToggle: () => toggleCustomFolders(`${SHARED_KEY_PREFIX}${account.id}`),
|
||||
// Another login's shared mailbox is opened by switching to that login.
|
||||
selected: selectedKeyword || !isViewing || !isActive ? "" : selectedMailbox,
|
||||
onSelect: isActive
|
||||
? (mailboxId) => onAccountMailboxSelect?.(null, mailboxId)
|
||||
: (mailboxId) => onLoginSharedMailboxSelect?.(account.id, mailboxId),
|
||||
active: isActive,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -2053,120 +2229,12 @@ function SidebarImpl({
|
||||
|
||||
{tagsAboveShared && renderTags()}
|
||||
|
||||
{!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("shared")}
|
||||
expanded={sharedExpanded}
|
||||
onToggle={toggleShared}
|
||||
isCollapsed={isCollapsed}
|
||||
testId="section-shared"
|
||||
/>
|
||||
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{sharedAccounts.map((account) => {
|
||||
const accountExpanded = expandedSharedAccounts.has(account.id);
|
||||
const menuAccountId = sharedAccountMenuId(account.accountId);
|
||||
const compact = compactSharedMailboxes ? compactSharedMailbox(account) : null;
|
||||
if (compact) {
|
||||
// A row without folders below it has no chevron, so what
|
||||
// else hangs below it (Scheduled, tags) stays shown.
|
||||
const compactExpanded = !isCollapsed
|
||||
&& (expandedFolders.has(compact.id) || compact.children.length === 0);
|
||||
const scheduledCount = menuAccountId ? (scheduledTotalByAccount?.[menuAccountId] ?? 0) : 0;
|
||||
const scheduledRow = () => scheduledCount > 0
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId, depth: 1 });
|
||||
// Scheduled goes after Drafts, as under a header row; with
|
||||
// Drafts left out (empty), after the other folders.
|
||||
const hasDrafts = compact.children.some((child) => child.role === 'drafts');
|
||||
// "Mark subfolders read" covers the inbox's own subfolders
|
||||
// only, not the account's folders listed below the row.
|
||||
const inboxHasSubfolders = (account.children.find((child) => child.id === compact.id)?.children.length ?? 0) > 0;
|
||||
return (
|
||||
<div key={account.id} data-testid="compact-shared-mailbox">
|
||||
<MailboxTreeItem
|
||||
node={compact}
|
||||
sharedAccountName={account.name}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={(e, node) => handleCompactSharedContextMenu(e, node, compact.id, menuAccountId, inboxHasSubfolders)}
|
||||
renderAfterChild={(child) => child.role === 'drafts' && scheduledRow()}
|
||||
/>
|
||||
{compactExpanded && !hasDrafts && scheduledRow()}
|
||||
{compactExpanded && menuAccountId && activeAccountId && (
|
||||
<SharedAccountTags
|
||||
loginId={activeAccountId}
|
||||
accountId={menuAccountId}
|
||||
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
|
||||
expandedTags={expandedTags}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={account.id}>
|
||||
<SidebarSectionHeader
|
||||
label={account.name}
|
||||
expanded={accountExpanded}
|
||||
onToggle={() => toggleSharedAccount(account.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
onContextMenu={menuAccountId
|
||||
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
|
||||
: undefined}
|
||||
/>
|
||||
{accountExpanded && !isCollapsed && (
|
||||
<>
|
||||
{account.children.map((child) => (
|
||||
<Fragment key={child.id}>
|
||||
<MailboxTreeItem
|
||||
node={child}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={onMailboxSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={onUnreadFilterClick}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
{child.role === 'drafts' && menuAccountId
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
||||
</Fragment>
|
||||
))}
|
||||
{menuAccountId && !account.children.some((child) => child.role === 'drafts')
|
||||
&& renderScheduledRow(`${account.id}-scheduled`, { accountId: menuAccountId })}
|
||||
{menuAccountId && activeAccountId && (
|
||||
<SharedAccountTags
|
||||
loginId={activeAccountId}
|
||||
accountId={menuAccountId}
|
||||
selectedKeyword={selectedKeywordAccountId === menuAccountId ? selectedKeyword : null}
|
||||
expandedTags={expandedTags}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!useMultiAccount && showSecondary && renderSharedSection(sharedAccounts, {
|
||||
expanded: sharedExpanded,
|
||||
onToggle: toggleShared,
|
||||
selected: selectedKeyword ? "" : selectedMailbox,
|
||||
onSelect: onMailboxSelect,
|
||||
})}
|
||||
|
||||
{!tagsAboveShared && renderTags()}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ import { FilterPanelHost } from "@/components/search/filter-panel-host";
|
||||
import { SearchFilterPanel, SELECT_CLASS } from "@/components/search/search-filter-panel";
|
||||
import type { ContactSuggestion } from "@/lib/search-suggestions";
|
||||
import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { useSearchHistoryStore } from "@/stores/search-history-store";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
@@ -503,6 +504,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
batchMarkAsSpam,
|
||||
batchUndoSpam,
|
||||
accountMailboxes,
|
||||
accountSharedMailboxes,
|
||||
viewingAccountId,
|
||||
selectAccountMailbox,
|
||||
setViewingAccount,
|
||||
@@ -591,6 +593,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
batchMarkAsSpam: state.batchMarkAsSpam,
|
||||
batchUndoSpam: state.batchUndoSpam,
|
||||
accountMailboxes: state.accountMailboxes,
|
||||
accountSharedMailboxes: state.accountSharedMailboxes,
|
||||
viewingAccountId: state.viewingAccountId,
|
||||
selectAccountMailbox: state.selectAccountMailbox,
|
||||
setViewingAccount: state.setViewingAccount,
|
||||
@@ -3309,10 +3312,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string) => {
|
||||
// `activeClient` stands in for `client` when the caller has just switched
|
||||
// logins and this render's `client` is still the previous one.
|
||||
const handleAccountMailboxSelect = async (accountId: string | null, mailboxId: string, activeClient?: IJMAPClient) => {
|
||||
const viewingClient = accountId
|
||||
? useAuthStore.getState().getClientForAccount(accountId) ?? client
|
||||
: client;
|
||||
: activeClient ?? client;
|
||||
selectAccountMailbox(accountId, mailboxId);
|
||||
selectEmail(null);
|
||||
if (isMobile) {
|
||||
@@ -3337,6 +3342,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Pro sidebar: a shared mailbox listed under another login (#1486). Shared
|
||||
// mail is read through the active login - its folder list, actions and
|
||||
// tags all assume it - so switch to that login first, wait for its folders
|
||||
// and open the mailbox there, as a folder link to another login does.
|
||||
const handleLoginSharedMailboxSelect = async (loginId: string, mailboxId: string) => {
|
||||
if (useAuthStore.getState().activeAccountId !== loginId) {
|
||||
const before = useEmailStore.getState().mailboxes;
|
||||
await switchAccount(loginId);
|
||||
// Overtaken or failed: the folders on screen are not that login's.
|
||||
if (useAuthStore.getState().activeAccountId !== loginId) return;
|
||||
const fresh = await freshMailboxes(before);
|
||||
if (useAuthStore.getState().activeAccountId !== loginId) return;
|
||||
if (!fresh.some((mb) => mb.id === mailboxId)) return;
|
||||
}
|
||||
await handleAccountMailboxSelect(null, mailboxId, useAuthStore.getState().client ?? undefined);
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
// A shared account's Scheduled row arrives as "__scheduled__:<accountId>";
|
||||
// the store only ever sees the plain virtual id plus the account scope.
|
||||
@@ -5011,6 +5033,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
mvShell={mvShell}
|
||||
multiAccountMode={isEmbedded}
|
||||
accountMailboxes={accountMailboxes}
|
||||
accountSharedMailboxes={accountSharedMailboxes}
|
||||
onLoginSharedMailboxSelect={handleLoginSharedMailboxSelect}
|
||||
viewingAccountId={viewingAccountId}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -45,6 +45,9 @@ export function useProMultiAccountMailboxes(): void {
|
||||
const connected = connectedSignature ? connectedSignature.split("\n") : [];
|
||||
if (!proInterface && !isEmbedded && !(mountainView && connected.length > 1)) return;
|
||||
if (connected.length === 0) return;
|
||||
// The Pro sidebar lists each login's shared mailboxes under it (#1486);
|
||||
// Mountain View's account popover only counts the login's own unread.
|
||||
const withShared = proInterface || isEmbedded;
|
||||
|
||||
if (lastActive.current !== null && lastActive.current !== activeAccountId) {
|
||||
invalidateUnifiedMailboxes();
|
||||
@@ -60,7 +63,7 @@ export function useProMultiAccountMailboxes(): void {
|
||||
const client = getClientForAccount(id);
|
||||
if (!client || loaded.current.get(id) === client) continue;
|
||||
loaded.current.set(id, client);
|
||||
void loadAccountMailboxes(client, id).then((ok) => {
|
||||
void loadAccountMailboxes(client, id, { withShared }).then((ok) => {
|
||||
// Try a failed login again on the next run, as the identities hook does.
|
||||
if (!ok && loaded.current.get(id) === client) loaded.current.delete(id);
|
||||
});
|
||||
|
||||
@@ -119,6 +119,22 @@ describe('unified scope folder lists', () => {
|
||||
expect(useEmailStore.getState().accountMailboxes['jmap-b']?.[0].id).toBe('b-inbox');
|
||||
});
|
||||
|
||||
it('keeps a login\'s shared mailboxes apart from its own folders (#1486)', async () => {
|
||||
const b = fakeClient('b');
|
||||
const shared = { ...inbox('g1-inbox', 2), isShared: true, accountId: 'g1', accountName: 'Support' } as Mailbox;
|
||||
b.getAllMailboxes.mockResolvedValue([inbox('b-inbox', 1), shared]);
|
||||
connect({ b });
|
||||
useEmailStore.setState({ accountSharedMailboxes: {} });
|
||||
|
||||
await loadAccountMailboxes(b as unknown as IJMAPClient, 'b', { withShared: true });
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(b.getAllMailboxes).toHaveBeenCalledTimes(1);
|
||||
// Role lookups in b's own list must never land on the shared inbox.
|
||||
expect(state.accountMailboxes.b.map((m) => m.id)).toEqual(['b-inbox']);
|
||||
expect(state.accountSharedMailboxes.b.map((m) => m.id)).toEqual(['g1-inbox']);
|
||||
});
|
||||
|
||||
it('retries a login whose fetch failed', async () => {
|
||||
const a = fakeClient('a');
|
||||
a.getMailboxes.mockRejectedValueOnce(new Error('offline'));
|
||||
|
||||
+24
-4
@@ -212,6 +212,13 @@ interface EmailStore {
|
||||
* `mailboxes` for back-compat with the single-account view.
|
||||
*/
|
||||
accountMailboxes: Record<string, Mailbox[]>;
|
||||
/**
|
||||
* The shared (group/delegated) mailboxes each login can reach, keyed by
|
||||
* login. Kept apart from `accountMailboxes`, which holds a login's own
|
||||
* folders only, so nothing that looks up a role there picks a shared one.
|
||||
* Only the Pro sidebar reads it, to list them under each login (#1486).
|
||||
*/
|
||||
accountSharedMailboxes: Record<string, Mailbox[]>;
|
||||
/**
|
||||
* When set, the mail view is reading from this account instead of the
|
||||
* global active one. `null` means "use the global active account" - i.e.
|
||||
@@ -1853,12 +1860,24 @@ async function unifiedMailboxesFor(
|
||||
* the unified scope uses, so the two never fetch the same list twice and a
|
||||
* list already there is not fetched again until that login reports a change.
|
||||
*/
|
||||
export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise<boolean> {
|
||||
export async function loadAccountMailboxes(
|
||||
client: IJMAPClient,
|
||||
accountId: string,
|
||||
opts: { withShared?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const withShared = opts.withShared ?? false;
|
||||
try {
|
||||
const { mailboxes, fresh } = await unifiedMailboxesFor(client, false);
|
||||
if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return true;
|
||||
const { mailboxes, fresh } = await unifiedMailboxesFor(client, withShared);
|
||||
const state = useEmailStore.getState();
|
||||
if (!fresh && state.accountMailboxes[accountId] && (!withShared || state.accountSharedMailboxes[accountId])) return true;
|
||||
// With the shared ones the list is getAllMailboxes: own folders go where
|
||||
// they always went, the shared ones beside them.
|
||||
const own = withShared ? mailboxes.filter((m) => !m.isShared) : mailboxes.slice();
|
||||
useEmailStore.setState((state) => ({
|
||||
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() },
|
||||
accountMailboxes: { ...state.accountMailboxes, [accountId]: own },
|
||||
...(withShared
|
||||
? { accountSharedMailboxes: { ...state.accountSharedMailboxes, [accountId]: mailboxes.filter((m) => m.isShared) } }
|
||||
: {}),
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -3238,6 +3257,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
accountMailboxes: {},
|
||||
accountSharedMailboxes: {},
|
||||
viewingAccountId: null,
|
||||
selectedEmail: null,
|
||||
selectedMailbox: "",
|
||||
|
||||
Reference in New Issue
Block a user