fix: offer the tags of the selection's account in the context menu, like the toolbar

This commit is contained in:
Linus Rath
2026-10-28 08:58:19 +01:00
parent 33ca8efb0f
commit 7f751e7de2
3 changed files with 106 additions and 3 deletions
@@ -0,0 +1,96 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
import { EmailContextMenu } from '../email-context-menu';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { loadSharedTags, resetSharedTags, sharedTagsKey, useSharedTagsStore } from '@/stores/shared-tags-store';
// The context menu's tag list for a selection offers the tags of the
// selection's account (commonTagScope, like the toolbar), not those of the
// row that was right-clicked.
const TEAM = { loginId: 'login-1', accountId: 'team' };
// A message in the group account, right-clicked.
const email = {
id: 'e1',
threadId: 't1',
mailboxIds: { 'team:inbox': true },
keywords: { $seen: true },
from: [{ email: 'alice@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
size: 100,
sourceClientAccountId: 'login-1',
sourceAccountId: 'team',
} as unknown as Email;
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
function openTags(batchTagScope: typeof TEAM | null) {
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
isMultiSelect
selectedCount={2}
batchTagIds={[]}
batchPartialTagIds={[]}
batchTagScope={batchTagScope}
onBatchToggleTag={vi.fn()}
/>,
);
act(() => { fireEvent.mouseEnter(screen.getByTestId('ctx-tag')); });
}
beforeEach(async () => {
resetSharedTags();
// The group's tags come through the real loading path.
const client = {
getAccountId: () => 'me',
getSieveAccountId: () => 'me',
supportsSieve: () => false,
getSharedAccounts: () => [],
getAccountInfo: () => ({ isPersonal: false }),
getPrincipalTypes: async () => ({ team: 'group' }),
advertisesBulwarkMetadata: () => true,
supportsBulwarkMetadata: () => true,
getBulwarkMetadata: async () => ({ tags: { tags: { urgent: { label: 'Team urgent', color: 'red' } } } }),
discoverKeywords: async () => ({ keywords: {}, scanned: 0, total: 0, complete: true }),
};
useAuthStore.setState({
client: client as never,
activeAccountId: 'login-1',
getClientForAccount: ((id: string) => (id === 'login-1' ? client : undefined)) as never,
});
useSettingsStore.setState({ emailKeywords: [{ id: 'mine', label: 'Mine', color: 'blue' }], nestedTags: false });
await loadSharedTags(sharedTagsKey(TEAM.loginId, TEAM.accountId), TEAM.accountId, client);
expect(useSharedTagsStore.getState().entries[sharedTagsKey(TEAM.loginId, TEAM.accountId)]?.status).toBe('ready');
});
afterEach(() => resetSharedTags());
describe('EmailContextMenu tags for a selection', () => {
it('offers the group\'s shared tags when the whole selection is in the group', async () => {
openTags(TEAM);
expect(await screen.findByText('Team urgent')).toBeInTheDocument();
expect(screen.queryByText('Mine')).not.toBeInTheDocument();
});
it('offers the own tags when the selection mixes accounts, even from a group row', async () => {
openTags(null);
expect(await screen.findByText('Mine')).toBeInTheDocument();
expect(screen.queryByText('Team urgent')).not.toBeInTheDocument();
});
});
+9 -3
View File
@@ -4,7 +4,7 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailStore, removalFolder, canRemoveFromFolder, emailLinkSource } from "@/stores/email-store";
import { useEmailTagScope } from "@/hooks/use-account-tags";
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
@@ -113,6 +113,11 @@ interface EmailContextMenuProps {
/** Tags every selected message carries, and those only some of them do. */
batchTagIds?: string[];
batchPartialTagIds?: string[];
/**
* The account of the whole selection (commonTagScope): its tags are
* offered. Null when the selection mixes accounts: the user's own tags.
*/
batchTagScope?: TagScope | null;
/** Takes the tag off when the whole selection has it, else puts it on all. */
onBatchToggleTag?: (tagId: string) => void;
onBatchDelete?: () => void;
@@ -180,6 +185,7 @@ export function EmailContextMenu({
onBatchMarkAsRead,
batchTagIds,
batchPartialTagIds,
batchTagScope = null,
onBatchToggleTag,
onBatchDelete,
onBatchArchive,
@@ -207,7 +213,7 @@ export function EmailContextMenu({
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentTagIds = getEmailTagIds(email.keywords);
// The row's account names the tags, for a selection too: it is part of it.
// The row's account names its tags; a selection uses batchTagScope.
const tagScope = useEmailTagScope(email);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
@@ -500,7 +506,7 @@ export function EmailContextMenu({
selectedIds={batchTagIds ?? []}
partialIds={batchPartialTagIds}
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
scope={tagScope}
scope={batchTagScope}
/>
) : (
<TagPicker
+1
View File
@@ -1173,6 +1173,7 @@ export function EmailList({
onBatchMarkAsRead={(read) => markSelectionRead(read)}
batchTagIds={selectionTags.all}
batchPartialTagIds={selectionTags.some}
batchTagScope={selectionTags.scope}
onBatchToggleTag={handleBatchToggleTag}
onBatchDelete={async () => {
if (!client) return;