From 7f751e7de2ae1f6a5773e8d5b0eed15727f4fa00 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:58:19 +0100 Subject: [PATCH] fix: offer the tags of the selection's account in the context menu, like the toolbar --- .../email-context-menu-shared-tags.test.tsx | 96 +++++++++++++++++++ components/email/email-context-menu.tsx | 12 ++- components/email/email-list.tsx | 1 + 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 components/email/__tests__/email-context-menu-shared-tags.test.tsx diff --git a/components/email/__tests__/email-context-menu-shared-tags.test.tsx b/components/email/__tests__/email-context-menu-shared-tags.test.tsx new file mode 100644 index 000000000..9f9e340a3 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shared-tags.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 1ec0402d4..161e65e09 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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} /> ) : ( markSelectionRead(read)} batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} + batchTagScope={selectionTags.scope} onBatchToggleTag={handleBatchToggleTag} onBatchDelete={async () => { if (!client) return;