mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'slots/mail' into feat/plugin-system-audit
# Conflicts: # lib/__tests__/plugin-slot-catalog.test.ts
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
|
||||
// Plugin surfaces of the composer: frames above and below the message text
|
||||
// (fed the draft, at most every 2 seconds), and entries in the Send and
|
||||
// Attach menus.
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onBeforeAttachmentUpload: { intercept: async () => true },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyHasSignature: () => false,
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DRAFT = {
|
||||
to: 'bob@example.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: 'Lunch',
|
||||
body: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const draftOf = (el: HTMLElement) => slotProps(el).draft as { subject: string; to: string[]; htmlBody: string; identityId: string };
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('composer-above-body and composer-below-body', () => {
|
||||
it('put the frames around the message text with the draft', () => {
|
||||
offerSlots('p1', ['composer-above-body', 'composer-below-body']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const above = screen.getByTestId('slot-composer-above-body');
|
||||
const below = screen.getByTestId('slot-composer-below-body');
|
||||
const editor = screen.getByTestId('rich-text-editor');
|
||||
expect(follows(screen.getByTestId('composer-subject'), above)).toBe(true);
|
||||
expect(follows(above, editor)).toBe(true);
|
||||
expect(follows(editor, below)).toBe(true);
|
||||
expect(draftOf(above)).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'], htmlBody: '<p>Hello there</p>', identityId: 'id-me' });
|
||||
expect(draftOf(below).subject).toBe('Lunch');
|
||||
});
|
||||
|
||||
it('send a changed draft only after 2 seconds without changes', () => {
|
||||
offerSlots('p1', ['composer-above-body']);
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
vi.useFakeTimers();
|
||||
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
|
||||
act(() => { vi.advanceTimersByTime(1000); });
|
||||
fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch at 12?' } });
|
||||
act(() => { vi.advanceTimersByTime(1900); });
|
||||
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch');
|
||||
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch at 12?');
|
||||
});
|
||||
|
||||
it('draw nothing without a plugin', () => {
|
||||
const { container } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-slot="composer-below-body"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-send-menu', () => {
|
||||
it('turns Send into a split button for plugin entries and sends the draft on click', async () => {
|
||||
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
// The phone and the desktop Send button are both in the DOM.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'more_actions' })[0]);
|
||||
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getAllByRole('menuitem', { name: 'Send with Later' })[0]); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [pluginId, surface, actionId, context] = dispatch.mock.calls[0];
|
||||
expect([pluginId, surface, actionId]).toEqual(['p', 'composer-send-menu', 'later']);
|
||||
expect((context as { draft: { subject: string } }).draft).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'] });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Send with Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the entries under Schedule send when the account has it', () => {
|
||||
useAuthStore.setState({
|
||||
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
|
||||
});
|
||||
act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
|
||||
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
|
||||
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
|
||||
expect(follows(schedule, plugin)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a plain Send button without plugin entries or scheduled send', () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'schedule_send' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-attach-menu', () => {
|
||||
it('turns Attach into a menu for plugin entries', async () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('composer-attach-menu'));
|
||||
expect(screen.getByRole('menuitem', { name: 'attach_from_device' })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'From the cloud' })); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', {});
|
||||
expect(screen.queryByRole('menuitem', { name: 'From the cloud' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the plain Attach button without plugin entries', () => {
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
|
||||
// Plugin surfaces of the message list: frames above the list and in the empty
|
||||
// list, buttons on each row (hover, and the row menu), and buttons in the
|
||||
// toolbar shown while messages are selected.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null }));
|
||||
vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
|
||||
vi.mock('@/stores/auth-store', async () => {
|
||||
const { create } = await import('zustand');
|
||||
return {
|
||||
useAuthStore: create(() => ({
|
||||
client: {}, identities: [], username: null, activeAccountId: 'acct-1',
|
||||
getAllConnectedClients: () => new Map(), getClientForAccount: () => null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { getAccountById: () => undefined, accounts: [] };
|
||||
return {
|
||||
useAccountStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const { EmailList } = await import('../email-list');
|
||||
const { MvListToolbar } = await import('../mv-list-toolbar');
|
||||
const { ThreadListItem } = await import('../thread-list-item');
|
||||
const { EmailContextMenu } = await import('../email-context-menu');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useUIStore } = await import('@/stores/ui-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { groupEmailsByThread } = await import('@/lib/thread-utils');
|
||||
|
||||
const mail = (id: string): Email => ({
|
||||
id,
|
||||
threadId: `thread-${id}`,
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2026-09-30T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: `Mail ${id}`,
|
||||
hasAttachment: false,
|
||||
});
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const initialEmailState = useEmailStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useEmailStore.setState(initialEmailState, true);
|
||||
useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set<string>() });
|
||||
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
|
||||
useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('message-list-header', () => {
|
||||
it('shows the plugin frame above the list with the folder', () => {
|
||||
offerSlots('p1', ['message-list-header']);
|
||||
render(<EmailList emails={[mail('a')]} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-message-list-header');
|
||||
expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' });
|
||||
// Above the scrolling list, not inside it.
|
||||
expect(frame.closest('[data-tour="email-list"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
const { container } = render(<EmailList emails={[mail('a')]} />);
|
||||
expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-list-empty', () => {
|
||||
it('shows the frame under the "no messages" text of an empty list', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
render(<EmailList emails={[]} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-message-list-empty');
|
||||
expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false });
|
||||
const text = screen.getByText('no_emails_description');
|
||||
expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says when the empty list is a search', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
useEmailStore.setState({ searchQuery: 'invoice' });
|
||||
render(<EmailList emails={[]} />);
|
||||
|
||||
expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true);
|
||||
});
|
||||
|
||||
it('is not there when the list has messages, or without a plugin', () => {
|
||||
offerSlots('p1', ['message-list-empty']);
|
||||
const { unmount } = render(<EmailList emails={[mail('a')]} />);
|
||||
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
|
||||
unmount();
|
||||
clearOffers();
|
||||
render(<EmailList emails={[]} />);
|
||||
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-bulk-actions', () => {
|
||||
it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => {
|
||||
const emails = [mail('a'), mail('b'), mail('c')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<EmailList emails={emails} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Make tasks' });
|
||||
// In the toolbar that holds the app's own batch buttons.
|
||||
const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!;
|
||||
expect(toolbar).toContainElement(button);
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] });
|
||||
});
|
||||
|
||||
it('has no plugin buttons without a selection or without a plugin', () => {
|
||||
const emails = [mail('a')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) });
|
||||
const { unmount } = render(<EmailList emails={emails} />);
|
||||
expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull();
|
||||
unmount();
|
||||
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>() });
|
||||
render(<EmailList emails={emails} />);
|
||||
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
|
||||
});
|
||||
|
||||
it('adds them to the Mountain View toolbar too', async () => {
|
||||
const emails = [mail('a'), mail('b')];
|
||||
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] });
|
||||
});
|
||||
|
||||
it('leaves the Mountain View toolbar alone without a selection', () => {
|
||||
useEmailStore.setState({ emails: [mail('a')] });
|
||||
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<MvListToolbar loadedCount={1} onRefresh={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message-row-actions', () => {
|
||||
const row = (email: Email) => {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return <ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />;
|
||||
};
|
||||
|
||||
it('adds the plugin buttons after the row\'s own hover buttons', async () => {
|
||||
act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }]));
|
||||
const onSelect = vi.fn();
|
||||
const [thread] = groupEmailsByThread([mail('a')]);
|
||||
render(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={onSelect} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Later' });
|
||||
const star = screen.getByTestId('hover-action-star');
|
||||
expect(star.parentElement).toContainElement(button);
|
||||
expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
|
||||
// The click is the plugin's, not a row click that opens the message.
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows them even when the user turned off every quick action', () => {
|
||||
useSettingsStore.setState({ hoverActions: [] } as never);
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
render(row(mail('a')));
|
||||
expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('honours the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }]));
|
||||
render(row(mail('a')));
|
||||
expect(screen.queryByRole('button', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
render(row(mail('a')));
|
||||
expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull();
|
||||
});
|
||||
|
||||
const menu = (props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) => render(
|
||||
<EmailContextMenu
|
||||
email={mail('a')}
|
||||
position={{ x: 0, y: 0 }}
|
||||
isOpen
|
||||
onClose={props.onClose ?? (() => {})}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('puts them in the row menu, which is what a long press opens on phones', async () => {
|
||||
act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
const onClose = vi.fn();
|
||||
menu({ onClose });
|
||||
|
||||
const item = screen.getByRole('menuitem', { name: 'Later' });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => {
|
||||
const { unmount } = menu();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
unmount();
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
menu({ isMultiSelect: true, selectedCount: 3 });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the menu entries to the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }]));
|
||||
menu();
|
||||
expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
|
||||
// Plugin surfaces of the open message: frames above the subject, under the
|
||||
// sender, between new text and quote, above a conversation; buttons in the
|
||||
// reader toolbar (More panel on phones); entries in an attachment's menu.
|
||||
|
||||
const device = vi.hoisted(() => ({ isMobile: false }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
|
||||
useMediaQuery: () => false,
|
||||
useIsMobile: () => device.isMobile,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
|
||||
PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
const { ReaderAboveQuoteSlot, QUOTE_ANCHOR_ATTR } = await import('../reader-plugin-surfaces');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
function mail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
...overrides,
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
function textMail(text: string, overrides: Partial<Email> = {}): Email {
|
||||
return mail({
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { '1': { value: text, isTruncated: false } },
|
||||
...overrides,
|
||||
} as Partial<Email>);
|
||||
}
|
||||
|
||||
const REPLY = 'Sounds good, see you then.\n\nOn Monday Bob wrote:\n> Shall we meet at ten?\n> Bob';
|
||||
|
||||
function viewer(email: Email, props: Partial<React.ComponentProps<typeof EmailViewer>> = {}) {
|
||||
return render(
|
||||
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...props} />,
|
||||
);
|
||||
}
|
||||
|
||||
const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const initialSettings = useSettingsStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
device.isMobile = false;
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
useSettingsStore.setState(initialSettings, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearOffers();
|
||||
setActionDispatcher(null);
|
||||
});
|
||||
|
||||
describe('reader-header and reader-below-subject', () => {
|
||||
it('put the frames above the subject and under the sender, with the message', () => {
|
||||
offerSlots('p1', ['reader-header', 'reader-below-subject']);
|
||||
const { container } = viewer(textMail('Hi'));
|
||||
|
||||
const header = screen.getByTestId('slot-reader-header');
|
||||
const below = screen.getByTestId('slot-reader-below-subject');
|
||||
const subject = screen.getByRole('heading', { name: 'Hello there' });
|
||||
const body = container.querySelector('.email-content-wrapper')!;
|
||||
expect(follows(header, subject)).toBe(true);
|
||||
expect(follows(subject, below)).toBe(true);
|
||||
expect(follows(below, body)).toBe(true);
|
||||
expect((slotProps(header).email as { id: string }).id).toBe('e1');
|
||||
expect((slotProps(below).email as { subject: string }).subject).toBe('Hello there');
|
||||
});
|
||||
|
||||
it('draw nothing without a plugin', () => {
|
||||
const { container } = viewer(textMail('Hi'));
|
||||
expect(container.querySelector('[data-plugin-slot="reader-header"]')).toBeNull();
|
||||
expect(container.querySelector('[data-plugin-slot="reader-below-subject"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-above-quote', () => {
|
||||
it('sits between the new text of a plain-text reply and its folded quote', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const { container } = viewer(textMail(REPLY));
|
||||
|
||||
const frame = screen.getByTestId('slot-reader-above-quote');
|
||||
const quote = container.querySelector('.email-content-text details')!;
|
||||
expect(quote).not.toBeNull();
|
||||
expect(follows(frame, quote)).toBe(true);
|
||||
expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true);
|
||||
expect((slotProps(frame).email as { id: string }).id).toBe('e1');
|
||||
});
|
||||
|
||||
it('is not mounted for a message without a quote, or without a plugin', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const { unmount } = viewer(textMail('Just some text.'));
|
||||
expect(screen.queryByTestId('slot-reader-above-quote')).toBeNull();
|
||||
unmount();
|
||||
clearOffers();
|
||||
const { container } = viewer(textMail(REPLY));
|
||||
expect(container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
|
||||
});
|
||||
|
||||
it('lays the frame over an HTML body and makes room for it before the quote toggle', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const iframe = document.createElement('iframe');
|
||||
host.appendChild(iframe);
|
||||
const doc = iframe.contentDocument!;
|
||||
doc.body.innerHTML = '<p>New text</p><button data-quote-toggle>...</button><blockquote type="cite">Old</blockquote>';
|
||||
|
||||
const { unmount } = render(
|
||||
<ReaderAboveQuoteSlot root={doc.body} frame={iframe} extraProps={{ email: { id: 'e1' } }} />,
|
||||
{ container: host.appendChild(document.createElement('div')) },
|
||||
);
|
||||
|
||||
const anchor = doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!;
|
||||
expect(anchor.nextElementSibling).toBe(doc.querySelector('[data-quote-toggle]'));
|
||||
const overlay = document.querySelector('[data-plugin-quote-overlay]')!;
|
||||
expect(within(overlay as HTMLElement).getByTestId('slot-reader-above-quote')).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('shows in a conversation message on phones too', () => {
|
||||
offerSlots('p1', ['reader-above-quote']);
|
||||
const email = textMail(REPLY, { keywords: {} } as Partial<Email>);
|
||||
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId('slot-reader-above-quote')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('thread-summary', () => {
|
||||
const conversation = [
|
||||
textMail('First', { id: 'm1', receivedAt: '2026-01-01T00:00:00Z' } as Partial<Email>),
|
||||
textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
|
||||
];
|
||||
|
||||
it('sits above the messages of the phone conversation view', () => {
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
|
||||
|
||||
const frame = screen.getByTestId('slot-thread-summary');
|
||||
expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] });
|
||||
expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('sits above a Mountain View conversation', () => {
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 });
|
||||
viewer(conversation[1], { mvConversation: conversation });
|
||||
|
||||
expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] });
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin, and nothing for a single message', () => {
|
||||
const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
|
||||
const { unmount } = render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
|
||||
expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
|
||||
unmount();
|
||||
offerSlots('p1', ['thread-summary']);
|
||||
viewer(conversation[1]);
|
||||
expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-actions', () => {
|
||||
it('adds the plugin buttons to the reader toolbar', async () => {
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
viewer(textMail('Hola'));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Translate' });
|
||||
expect(button.closest('[data-plugin-actions="reader-actions"]')).not.toBeNull();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('keeps to the folder roles the plugin asked for', () => {
|
||||
act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['junk'] }]));
|
||||
viewer(textMail('Hola'));
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('puts them in the More panel on phones', async () => {
|
||||
device.isMobile = true;
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
viewer(textMail('Hola'));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
|
||||
const item = screen.getByRole('menuitem', { name: 'Translate', hidden: true });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('draws nothing without a plugin', () => {
|
||||
const { container } = viewer(textMail('Hola'));
|
||||
expect(container.querySelector('[data-plugin-actions="reader-actions"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment-menu', () => {
|
||||
const withAttachments = () => textMail('See attached', {
|
||||
hasAttachment: true,
|
||||
attachments: [
|
||||
{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' },
|
||||
{ partId: '3', blobId: 'blob-png', name: 'photo.png', type: 'image/png', size: 99, disposition: 'attachment' },
|
||||
],
|
||||
} as Partial<Email>);
|
||||
|
||||
it('opens the plugin entries from a menu button on the attachment and sends the attachment', async () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud', mimeTypes: ['application/pdf'] }]));
|
||||
viewer(withAttachments());
|
||||
|
||||
// Only the PDF gets the menu (desktop chip and phone chip are both in the DOM).
|
||||
const buttons = document.querySelectorAll('[data-attachment-plugin-menu]');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
for (const b of buttons) {
|
||||
expect(b.closest('[data-attachment-name]')?.getAttribute('data-attachment-name')).toBe('invoice.pdf');
|
||||
}
|
||||
fireEvent.click(buttons[0]);
|
||||
const item = await screen.findByRole('menuitem', { name: 'Save to cloud' });
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'attachment-menu', 'save', {
|
||||
emailId: 'e1', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the menu to attachments in a conversation message', () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud' }]));
|
||||
const email = withAttachments();
|
||||
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
|
||||
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
|
||||
|
||||
expect(document.querySelectorAll('[data-attachment-plugin-menu]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('draws no menu button without a plugin entry', () => {
|
||||
viewer(withAttachments());
|
||||
expect(document.querySelector('[data-attachment-plugin-menu]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronDown, type AppIcon } from "@/components/icons";
|
||||
import { Check, ChevronDown, iconForName, Puzzle, type AppIcon } from "@/components/icons";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposerToolbarMenuProps {
|
||||
@@ -138,3 +140,47 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginItemsProps {
|
||||
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
|
||||
surface: string;
|
||||
/** What the click is about; a function is read at click time. */
|
||||
context: Record<string, unknown> | (() => Record<string, unknown>);
|
||||
/** Called before the plugin gets the click, e.g. to close the menu. */
|
||||
onSelect?: () => void;
|
||||
/** Draw a separator before the entries (when the menu has its own above). */
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
/** Entries plugins declared for a composer menu, drawn like the menu's own. */
|
||||
export function ComposerPluginMenuItems({ surface, context, onSelect, separator }: PluginItemsProps) {
|
||||
const actions = usePluginActions(surface);
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{separator && <div role="separator" className="my-1 h-px bg-border" />}
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
return (
|
||||
<button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => {
|
||||
onSelect?.();
|
||||
void runAction(action, surface, typeof context === "function" ? context() : context);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground",
|
||||
action.danger && "text-destructive",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">{action.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Reply,
|
||||
Forward,
|
||||
} from "@/components/icons";
|
||||
import { ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { ComposerPluginMenuItems, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { schedulePresets } from "@/lib/schedule-presets";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -63,6 +63,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
|
||||
@@ -382,6 +383,7 @@ export function EmailComposer({
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
|
||||
// Per-message format (#1022). Seeded from the "plain text only" setting -
|
||||
@@ -2068,6 +2070,12 @@ export function EmailComposer({
|
||||
const pluginAttachmentSourceProps = useMemo(() => ({ onAttach: handlePluginAttachmentAdd }), [handlePluginAttachmentAdd]);
|
||||
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
|
||||
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
|
||||
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
|
||||
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
|
||||
// Plugin entries make the Send and Attach buttons menus even where the app
|
||||
// itself has nothing else to put there.
|
||||
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
|
||||
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
|
||||
|
||||
// Files from the Files app are already blobs on the server, so they are
|
||||
// attached by blobId with no download or upload (#1179). Stalwart resolves a
|
||||
@@ -2377,6 +2385,45 @@ export function EmailComposer({
|
||||
// Keep saveDraftRef pointing to latest saveDraft
|
||||
saveDraftRef.current = saveDraft;
|
||||
|
||||
// The draft as plugins see it: onDraftChange, the frames above and below
|
||||
// the body, and clicks on plugin entries in the Send menu.
|
||||
const draftSnapshot = () => ({
|
||||
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
|
||||
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: plainTextMode ? '' : body,
|
||||
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||
identityId: selectedIdentityId || '',
|
||||
attachments: attachments
|
||||
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
});
|
||||
const draftSnapshotRef = useRef(draftSnapshot);
|
||||
draftSnapshotRef.current = draftSnapshot;
|
||||
|
||||
// composer-above-body / composer-below-body get the draft when they appear
|
||||
// and then 2 seconds after the last change, like onDraftChange: a new prop
|
||||
// object on every keystroke would post the whole body into their iframes.
|
||||
const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers;
|
||||
const [pluginDraft, setPluginDraft] = useState<ReturnType<typeof draftSnapshot> | null>(null);
|
||||
const pluginDraftShownRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!wantsPluginDraft) {
|
||||
pluginDraftShownRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!pluginDraftShownRef.current) {
|
||||
pluginDraftShownRef.current = true;
|
||||
setPluginDraft(draftSnapshotRef.current());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setPluginDraft(draftSnapshotRef.current()), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [wantsPluginDraft, toStr, ccStr, bccStr, subject, body, plainTextMode, selectedIdentityId, attachments]);
|
||||
const pluginDraftProps = useMemo(() => ({ draft: pluginDraft }), [pluginDraft]);
|
||||
const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current() }), []);
|
||||
|
||||
// Trigger auto-save when content changes (only if user modified something)
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
@@ -2396,18 +2443,7 @@ export function EmailComposer({
|
||||
// tracked via inflightSaveRef.
|
||||
saveTimeoutRef.current = null;
|
||||
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
|
||||
emailHooks.onDraftChange.emit({
|
||||
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
|
||||
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: plainTextMode ? '' : body,
|
||||
textBody: plainTextMode ? body : htmlToPlainText(body),
|
||||
identityId: selectedIdentityId || '',
|
||||
attachments: attachments
|
||||
.filter(a => a.blobId && !a.uploading && !a.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
|
||||
});
|
||||
emailHooks.onDraftChange.emit(draftSnapshot());
|
||||
saveDraft({ auto: true });
|
||||
}, 2000);
|
||||
|
||||
@@ -3269,7 +3305,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
@@ -3286,7 +3322,8 @@ export function EmailComposer({
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
size="sm"
|
||||
className="h-9 rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
@@ -3296,15 +3333,23 @@ export function EmailComposer({
|
||||
</Button>
|
||||
{showSendMenu && (
|
||||
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
)}
|
||||
<ComposerPluginMenuItems
|
||||
surface="composer-send-menu"
|
||||
context={sendMenuContext}
|
||||
onSelect={() => setShowSendMenu(false)}
|
||||
separator={!!composerClient?.hasDelayedSend()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3626,6 +3671,9 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
{/* Body */}
|
||||
{plainTextMode ? (
|
||||
<textarea
|
||||
@@ -3684,6 +3732,9 @@ export function EmailComposer({
|
||||
dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
|
||||
/>
|
||||
) : null}
|
||||
{wantsPluginDraft && (
|
||||
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3822,7 +3873,7 @@ export function EmailComposer({
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() ? (
|
||||
{(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
|
||||
<ComposerToolbarMenu icon={Paperclip} label={t('attach')} testId="composer-attach-menu">
|
||||
{(close) => <>
|
||||
<ComposerToolbarMenuItem
|
||||
@@ -3831,13 +3882,16 @@ export function EmailComposer({
|
||||
>
|
||||
{t('attach_from_device')}
|
||||
</ComposerToolbarMenuItem>
|
||||
<ComposerToolbarMenuItem
|
||||
icon={FolderOpen}
|
||||
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
|
||||
testId="composer-attach-from-files"
|
||||
>
|
||||
{t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() && (
|
||||
<ComposerToolbarMenuItem
|
||||
icon={FolderOpen}
|
||||
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
|
||||
testId="composer-attach-from-files"
|
||||
>
|
||||
{t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-attach-menu" context={{}} onSelect={close} separator />
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
) : (
|
||||
@@ -3946,7 +4000,7 @@ export function EmailComposer({
|
||||
>
|
||||
{mvWindow ? <Trash2 className="w-5 h-5" /> : t('discard')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div ref={sendMenuRef} data-mv-send={mvWindow ? "" : undefined} className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
@@ -3962,7 +4016,8 @@ export function EmailComposer({
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
|
||||
className="rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
@@ -3974,15 +4029,23 @@ export function EmailComposer({
|
||||
role="menu"
|
||||
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
{composerClient?.hasDelayedSend() && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
)}
|
||||
<ComposerPluginMenuItems
|
||||
surface="composer-send-menu"
|
||||
context={sendMenuContext}
|
||||
onSelect={() => setShowSendMenu(false)}
|
||||
separator={!!composerClient?.hasDelayedSend()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
@@ -266,6 +267,23 @@ export function EmailContextMenu({
|
||||
// Stable, so the Rules entry does not re-resolve its account every render.
|
||||
const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]);
|
||||
|
||||
// The row's plugin buttons (message-row-actions). On phones the long press
|
||||
// opens this menu, so it is where those buttons live there.
|
||||
const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
|
||||
const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<PluginActions
|
||||
surface="message-row-actions"
|
||||
variant="menu"
|
||||
context={rowPluginContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={onClose}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// The menu's entries, composed below in the default layout's order or,
|
||||
// under Mountain View, in Gmail's.
|
||||
const replyBlock = (
|
||||
@@ -564,6 +582,7 @@ export function EmailContextMenu({
|
||||
{findBlock}
|
||||
<ContextMenuSeparator />
|
||||
{permalinkBlock}
|
||||
{rowPluginBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -581,6 +600,7 @@ export function EmailContextMenu({
|
||||
{spamBlock}
|
||||
<ContextMenuSeparator />
|
||||
{readItem}
|
||||
{rowPluginBlock}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -11,6 +11,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
@@ -27,6 +28,8 @@ interface EmailHoverActionsProps {
|
||||
onUndoSpam?: () => void;
|
||||
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||
spamApplicable?: boolean;
|
||||
/** Role of the folder the row is listed in; plugin buttons can be limited to some. */
|
||||
mailboxRole?: string | null;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
@@ -105,6 +108,7 @@ export function EmailHoverActions({
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
spamApplicable = true,
|
||||
mailboxRole = null,
|
||||
}: EmailHoverActionsProps) {
|
||||
const configuredHoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
|
||||
@@ -113,6 +117,9 @@ export function EmailHoverActions({
|
||||
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
const isMobile = useIsMobile();
|
||||
// Buttons plugins add to the row (message-row-actions), after the app's own.
|
||||
const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
|
||||
const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
|
||||
|
||||
// The tag quick-action opens the same picker the context menu and the reading
|
||||
// pane use. It is portalled to the body because every row sits inside a
|
||||
@@ -159,7 +166,7 @@ export function EmailHoverActions({
|
||||
const hoverBackgroundClassName = backgroundClassName;
|
||||
|
||||
if (isMobile) return null;
|
||||
if (hoverActions.length === 0) return null;
|
||||
if (hoverActions.length === 0 && pluginActionCount === 0) return null;
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||
e.stopPropagation();
|
||||
@@ -251,6 +258,18 @@ export function EmailHoverActions({
|
||||
</button>
|
||||
);
|
||||
});
|
||||
if (pluginActionCount > 0) {
|
||||
actionButtons.push(
|
||||
<PluginActions
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const tagPicker =
|
||||
tagPickerPos && onSetTag
|
||||
|
||||
@@ -33,6 +33,8 @@ import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
@@ -381,6 +383,19 @@ export function EmailList({
|
||||
);
|
||||
|
||||
const hasSelection = selectedEmailKeys.size > 0;
|
||||
// What plugin buttons in the selection toolbar act on: the JMAP ids of the
|
||||
// selected messages, thread members included.
|
||||
const selectedEmailIds = useMemo(
|
||||
() => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id),
|
||||
[emails, threadEmailsCache, selectedEmailKeys],
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const listSlotProps = useMemo(
|
||||
() => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }),
|
||||
[selectedMailbox, effectiveMailboxRole],
|
||||
);
|
||||
const isSearching = !!searchQuery || !isFilterEmpty(searchFilters);
|
||||
const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]);
|
||||
|
||||
const handleBatchMarkAsRead = async (read: boolean) => {
|
||||
if (!client || isProcessing) return;
|
||||
@@ -653,6 +668,14 @@ export function EmailList({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
{hasSelection && (
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={bulkActionContext}
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -708,6 +731,8 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
{/* Email List */}
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||
{/* Loading overlay */}
|
||||
@@ -739,6 +764,7 @@ export function EmailList({
|
||||
<p className="text-sm mt-1 text-muted-foreground">
|
||||
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
|
||||
</p>
|
||||
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -122,6 +122,8 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
|
||||
import type { TnefAttachment } from "@/lib/tnef";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { RulesPanel, useRulesAvailability } from "./rules-menu";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
@@ -1122,6 +1124,11 @@ export function EmailViewer({
|
||||
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
|
||||
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
|
||||
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
|
||||
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
|
||||
const threadSummaryProps = useMemo(
|
||||
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }),
|
||||
[email?.threadId, mvConversationIds],
|
||||
);
|
||||
// Mountain View: which other messages of the conversation are unfolded. A
|
||||
// conversation opens with its unread messages unfolded, as Gmail does, so
|
||||
// reading the thread reads all of them.
|
||||
@@ -1416,15 +1423,20 @@ export function EmailViewer({
|
||||
// bottom plugin category wrapper when something will fill it.
|
||||
const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0;
|
||||
const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0;
|
||||
const hasHeaderOffers = usePluginSlotOffers('reader-header').length > 0;
|
||||
const hasBelowSubjectOffers = usePluginSlotOffers('reader-below-subject').length > 0;
|
||||
const hasAboveQuoteOffers = usePluginSlotOffers('reader-above-quote').length > 0;
|
||||
// One projected view per message for all plugin slots on it, built only
|
||||
// when a plugin is there to see it. A fresh object on every render made
|
||||
// each slot post its props to the plugin again and re-run shouldShow.
|
||||
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers;
|
||||
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
|
||||
|| hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
|
||||
const pluginEmailView = useMemo(
|
||||
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
|
||||
[email, wantsPluginEmailView],
|
||||
);
|
||||
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
|
||||
@@ -2779,6 +2791,15 @@ export function EmailViewer({
|
||||
// Gates the quick reply on the iframe having loaded the current srcDoc, so
|
||||
// it doesn't flash in below a still-resizing iframe.
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// The plain-text body, where reader-above-quote mounts between text and quote.
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
// One object per body: React writes innerHTML again whenever this object
|
||||
// changes, which would drop what was mounted in the body and fold the quote
|
||||
// back up on every render.
|
||||
const plainBodyHtml = useMemo(
|
||||
() => ({ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }),
|
||||
[effectiveEmailContent.html],
|
||||
);
|
||||
// Tracks which parsed document we've already wired up, so setup runs exactly
|
||||
// once per srcDoc even though both the readiness poll below and the iframe
|
||||
// 'load' event can trigger it.
|
||||
@@ -3520,6 +3541,15 @@ export function EmailViewer({
|
||||
</Button>
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
{/* Phones get these in the More panel. */}
|
||||
{!isMobile && email && (
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
|
||||
@@ -4265,6 +4295,14 @@ export function EmailViewer({
|
||||
{t('keyboard_shortcuts')}
|
||||
</button>
|
||||
)}
|
||||
{email && (
|
||||
<PluginPanelEntries
|
||||
surface="reader-actions"
|
||||
context={readerActionContext}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
onRan={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
|
||||
@@ -4337,6 +4375,8 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* === SUBJECT BLOCK === */}
|
||||
<div className={cn(
|
||||
"bg-background",
|
||||
@@ -4468,6 +4508,11 @@ export function EmailViewer({
|
||||
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
|
||||
<div className="min-h-full flex flex-col">
|
||||
|
||||
{/* Above the messages of the open conversation. */}
|
||||
{mvOpenIndex >= 0 && (
|
||||
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
|
||||
)}
|
||||
|
||||
{/* Mountain View: the conversation's earlier messages, folded. */}
|
||||
{mvBefore.length > 0 && (
|
||||
<div data-mv-conversation-before="">
|
||||
@@ -4722,6 +4767,7 @@ export function EmailViewer({
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4795,6 +4841,7 @@ export function EmailViewer({
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -4918,6 +4965,8 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginSlot name="reader-below-subject" className="w-full bg-background" extraProps={pluginEmailProps} />
|
||||
|
||||
{/* Expandable Details (shared across mobile/tablet/desktop) */}
|
||||
{showFullHeaders && (() => {
|
||||
const translateAuthResult = (result?: string) => {
|
||||
@@ -5581,6 +5630,7 @@ export function EmailViewer({
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -5654,6 +5704,7 @@ export function EmailViewer({
|
||||
} satisfies AttachmentInfo,
|
||||
}}
|
||||
/>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -5714,6 +5765,8 @@ export function EmailViewer({
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
{/* Phones have no hover: the plugin menu stays visible. */}
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||
@@ -5790,6 +5843,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
|
||||
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
|
||||
<button
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
@@ -5839,7 +5893,7 @@ export function EmailViewer({
|
||||
|
||||
{/* Email Body */}
|
||||
<div className={cn(
|
||||
"email-content-wrapper overflow-x-auto",
|
||||
"email-content-wrapper overflow-x-auto relative",
|
||||
// Folded, the body stays mounted so unfolding does not reload it.
|
||||
(mvOpenFolded || mvAwaitingConversation) && "hidden",
|
||||
// Mountain View indents the body under the sender's name, as Gmail does.
|
||||
@@ -5871,9 +5925,10 @@ export function EmailViewer({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={setPlainBodyEl}
|
||||
className="email-content-text text-foreground"
|
||||
dir="auto"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
|
||||
dangerouslySetInnerHTML={plainBodyHtml}
|
||||
style={{
|
||||
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
|
||||
fontSize: '14px',
|
||||
@@ -5883,6 +5938,14 @@ export function EmailViewer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isBodyLoading && (
|
||||
<ReaderAboveQuoteSlot
|
||||
root={effectiveEmailContent.isHtml ? (iframeReady ? iframeRef.current?.contentDocument?.body ?? null : null) : plainBodyEl}
|
||||
frame={effectiveEmailContent.isHtml ? iframeRef.current : null}
|
||||
extraProps={pluginEmailProps}
|
||||
version={effectiveEmailContent.html}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
@@ -31,6 +31,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { cn, type MailboxNode } from "@/lib/utils";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
|
||||
interface MvListToolbarProps {
|
||||
/** Loaded conversation count, for the right-hand range readout. */
|
||||
@@ -606,6 +607,13 @@ export function MvListToolbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginActions
|
||||
surface="message-bulk-actions"
|
||||
context={{ emailIds: selectedEmails.map((email) => email.id) }}
|
||||
mailboxRole={currentRole ?? null}
|
||||
compact
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="ms-2 text-sm text-muted-foreground truncate">
|
||||
{tBatch("selected_messages", { count: selectionCount })}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
// Plugin surfaces of the open message that need more than a one-line mount:
|
||||
// the attachment menu, plugin entries in the phone "More" panel, and the
|
||||
// frame between a reply's own text and the quote under it.
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { runAction } from "@/lib/plugin-sandbox/actions";
|
||||
import { QUOTE_TOGGLE_ATTR } from "@/lib/quote-collapse";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── attachment-menu ─────────────────────────────────────────
|
||||
|
||||
export interface AttachmentMenuTarget {
|
||||
/** Parts without a blob (decrypted, unpacked from TNEF) get no menu: a plugin couldn't fetch them. */
|
||||
blobId?: string;
|
||||
name?: string | null;
|
||||
type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A "more" button on an attachment that opens the entries plugins declared
|
||||
* for `attachment-menu`, filtered by the attachment's MIME type. Nothing is
|
||||
* drawn when no plugin has an entry for this type, or the part has no blob.
|
||||
*/
|
||||
export function AttachmentPluginMenu({
|
||||
emailId,
|
||||
attachment,
|
||||
className,
|
||||
iconClassName = "w-4 h-4",
|
||||
}: {
|
||||
emailId: string | undefined;
|
||||
attachment: AttachmentMenuTarget;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const actions = usePluginActions("attachment-menu", { mimeType: attachment.type });
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
|
||||
const context = useMemo(() => ({
|
||||
emailId,
|
||||
blobId: attachment.blobId,
|
||||
name: attachment.name || "",
|
||||
type: attachment.type,
|
||||
size: attachment.size,
|
||||
}), [emailId, attachment.blobId, attachment.name, attachment.type, attachment.size]);
|
||||
|
||||
if (actions.length === 0 || !emailId || !attachment.blobId) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("p-1 hover:bg-accent rounded transition-colors", className)}
|
||||
title={t("more_actions")}
|
||||
aria-label={t("more_actions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={contextMenu.isOpen}
|
||||
data-attachment-plugin-menu=""
|
||||
onClick={(e) => openContextMenu(e, true)}
|
||||
>
|
||||
<MoreHorizontal className={cn(iconClassName, "text-foreground")} />
|
||||
</button>
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<PluginActions
|
||||
surface="attachment-menu"
|
||||
variant="menu"
|
||||
context={context}
|
||||
mimeType={attachment.type}
|
||||
onRan={closeContextMenu}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Entries in a phone slide-in panel ───────────────────────
|
||||
|
||||
/**
|
||||
* Plugin actions as rows of a full-height phone menu panel (the reader's
|
||||
* "More" panel), drawn like the panel's own rows. A separator leads them.
|
||||
*/
|
||||
export function PluginPanelEntries({
|
||||
surface,
|
||||
context,
|
||||
mailboxRole,
|
||||
onRan,
|
||||
}: {
|
||||
surface: string;
|
||||
context: Record<string, unknown>;
|
||||
mailboxRole?: string | null;
|
||||
onRan?: () => void;
|
||||
}) {
|
||||
const actions = usePluginActions(surface, { mailboxRole });
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
return (
|
||||
<button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
role="menuitem"
|
||||
title={action.pluginName}
|
||||
onClick={() => { onRan?.(); void runAction(action, surface, context); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
action.danger ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── reader-above-quote ──────────────────────────────────────
|
||||
|
||||
export const QUOTE_ANCHOR_ATTR = "data-plugin-quote-anchor";
|
||||
|
||||
/**
|
||||
* Puts an empty block right before the collapsed quote of a rendered body
|
||||
* and returns it: before the "•••" toggle setupQuoteCollapse added (HTML
|
||||
* body in its iframe), or before the <details> collapsePlainTextQuotes
|
||||
* wrapped the quote in (plain-text body). Null when the body has no quote
|
||||
* the viewer recognised.
|
||||
*/
|
||||
export function insertQuoteAnchor(root: HTMLElement): HTMLElement | null {
|
||||
const existing = root.querySelector<HTMLElement>(`[${QUOTE_ANCHOR_ATTR}]`);
|
||||
if (existing) return existing;
|
||||
const toggle = root.querySelector(`[${QUOTE_TOGGLE_ATTR}]`)
|
||||
?? root.querySelector(":scope > details");
|
||||
if (!toggle?.parentNode) return null;
|
||||
const anchor = root.ownerDocument.createElement("div");
|
||||
anchor.setAttribute(QUOTE_ANCHOR_ATTR, "");
|
||||
anchor.style.cssText = "display:block;margin:0;padding:0;border:0;height:0;white-space:normal;";
|
||||
toggle.parentNode.insertBefore(anchor, toggle);
|
||||
return anchor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `reader-above-quote` frame. In a plain-text body (same document) it
|
||||
* renders into the anchor. In an HTML body the anchor lives in the mail's
|
||||
* sandboxed iframe, which can't host a plugin frame, so the frame is laid
|
||||
* over the iframe at the anchor's place and the anchor is grown to the
|
||||
* frame's height to make room for it.
|
||||
*
|
||||
* `root` is the body element the quote was collapsed in (null while the body
|
||||
* isn't ready); `version` changes whenever that body is rendered anew.
|
||||
*/
|
||||
export function ReaderAboveQuoteSlot({
|
||||
root,
|
||||
frame,
|
||||
extraProps,
|
||||
version,
|
||||
}: {
|
||||
root: HTMLElement | null;
|
||||
/** The iframe holding `root`, for an HTML body. */
|
||||
frame?: HTMLIFrameElement | null;
|
||||
extraProps: Record<string, unknown>;
|
||||
version?: unknown;
|
||||
}) {
|
||||
const offered = usePluginSlotOffers("reader-above-quote").length > 0;
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!offered || !root) {
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
const next = insertQuoteAnchor(root);
|
||||
setAnchor(next);
|
||||
return () => {
|
||||
next?.remove();
|
||||
};
|
||||
}, [offered, root, version]);
|
||||
|
||||
if (!anchor) return null;
|
||||
const slot = <PluginSlot name="reader-above-quote" className="w-full" extraProps={extraProps} />;
|
||||
if (!frame || anchor.ownerDocument === document) {
|
||||
return createPortal(slot, anchor);
|
||||
}
|
||||
return <QuoteOverlay anchor={anchor} frame={frame}>{slot}</QuoteOverlay>;
|
||||
}
|
||||
|
||||
function QuoteOverlay({ anchor, frame, children }: { anchor: HTMLElement; frame: HTMLIFrameElement; children: React.ReactNode }) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [box, setBox] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const overlay = overlayRef.current;
|
||||
const host = overlay?.offsetParent as HTMLElement | null;
|
||||
if (!overlay) return;
|
||||
const place = () => {
|
||||
// The anchor's box is in the iframe's viewport; the iframe doesn't
|
||||
// scroll (it is as tall as the mail), so frame top + anchor top is
|
||||
// where the anchor sits on the page.
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const hostRect = host?.getBoundingClientRect() ?? { top: 0, left: 0 };
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const next = {
|
||||
top: frameRect.top - hostRect.top + frame.clientTop + anchorRect.top,
|
||||
left: frameRect.left - hostRect.left + frame.clientLeft,
|
||||
width: frame.clientWidth,
|
||||
};
|
||||
setBox((prev) => (prev && prev.top === next.top && prev.left === next.left && prev.width === next.width ? prev : next));
|
||||
};
|
||||
const fit = () => {
|
||||
// Make room in the mail for the frame. A mail scaled down to fit a
|
||||
// phone is drawn smaller than its layout, so undo that scale.
|
||||
const scale = anchor.offsetWidth > 0 ? anchor.getBoundingClientRect().width / anchor.offsetWidth : 1;
|
||||
const height = overlay.offsetHeight / (scale || 1);
|
||||
anchor.style.height = `${Math.ceil(height)}px`;
|
||||
place();
|
||||
};
|
||||
fit();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(overlay);
|
||||
observer.observe(frame);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", place);
|
||||
anchor.style.height = "0px";
|
||||
};
|
||||
}, [anchor, frame]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
data-plugin-quote-overlay=""
|
||||
className="absolute z-[1]"
|
||||
style={box ? { top: box.top, left: box.left, width: box.width } : { top: 0, left: 0, right: 0, visibility: "hidden" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
@@ -42,6 +42,10 @@ import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
|
||||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||||
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
|
||||
interface ThreadConversationViewProps {
|
||||
thread: ThreadGroup;
|
||||
@@ -170,6 +174,11 @@ export function ThreadConversationView({
|
||||
}: ThreadConversationViewProps) {
|
||||
const t = useTranslations();
|
||||
const externalContent = useConversationExternalContent();
|
||||
const emailIdList = emails.map((email) => email.id).join(',');
|
||||
const threadSummaryProps = useMemo(
|
||||
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [] }),
|
||||
[thread.latestEmail.threadId, emailIdList],
|
||||
);
|
||||
|
||||
// Track which emails are expanded (most recent by default)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
@@ -242,6 +251,7 @@ export function ThreadConversationView({
|
||||
|
||||
{/* Email Cards */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
|
||||
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
|
||||
{emails.map((email, index) => (
|
||||
<EmailCard
|
||||
@@ -467,6 +477,14 @@ export function ConversationEmailBody({
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||
const { client } = useAuthStore();
|
||||
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
|
||||
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0;
|
||||
const quoteSlotProps = useMemo(
|
||||
() => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }),
|
||||
[email, wantsQuoteSlot],
|
||||
);
|
||||
|
||||
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||
useEffect(() => {
|
||||
@@ -651,6 +669,13 @@ export function ConversationEmailBody({
|
||||
${isDark ? emailDarkModeCss() : ''}
|
||||
</style></head><body dir="auto">${emailContent.html}</body></html>`;
|
||||
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark]);
|
||||
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
|
||||
// One object per body: React writes innerHTML again whenever this object
|
||||
// changes, dropping whatever was mounted in the body.
|
||||
const plainBodyHtml = useMemo(
|
||||
() => ({ __html: sanitizePlainTextRenderedHtml(emailContent.html) }),
|
||||
[emailContent.html],
|
||||
);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -679,6 +704,7 @@ export function ConversationEmailBody({
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
setHtmlBodyEl(doc.body);
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
@@ -726,7 +752,7 @@ export function ConversationEmailBody({
|
||||
)}
|
||||
|
||||
{/* Email Body */}
|
||||
<div style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
|
||||
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
|
||||
{emailContent.isHtml ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
@@ -740,6 +766,7 @@ export function ConversationEmailBody({
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={setPlainBodyEl}
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none",
|
||||
!emailAlwaysLightMode && "dark:prose-invert",
|
||||
@@ -749,9 +776,15 @@ export function ConversationEmailBody({
|
||||
"[&_img]:max-w-full [&_img]:h-auto"
|
||||
)}
|
||||
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
|
||||
dangerouslySetInnerHTML={plainBodyHtml}
|
||||
/>
|
||||
)}
|
||||
<ReaderAboveQuoteSlot
|
||||
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
|
||||
frame={emailContent.isHtml ? iframeRef.current : null}
|
||||
extraProps={quoteSlotProps}
|
||||
version={emailContent.html}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
@@ -764,8 +797,8 @@ export function ConversationEmailBody({
|
||||
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
<button
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
|
||||
@@ -784,6 +817,8 @@ export function ConversationEmailBody({
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<AttachmentPluginMenu emailId={email.id} attachment={attachment} className="-ms-1.5 self-center p-2 rounded-lg" />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -783,6 +783,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1381,6 +1382,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Test helpers: pretend a running plugin offers a frame slot, without booting
|
||||
// a sandbox. Pair with a vi.mock of '@/components/plugins/plugin-iframe-slot'
|
||||
// (FakeIframeSlot below) so the "iframe" shows the props it would receive.
|
||||
|
||||
import React from 'react';
|
||||
import { register, deregister } from '@/lib/plugin-sandbox/registry';
|
||||
import type { ActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
|
||||
const offered = new Set<string>();
|
||||
|
||||
/** Registers a fake plugin that offers the given slots. */
|
||||
export function offerSlots(pluginId: string, slots: string[]): void {
|
||||
register({
|
||||
plugin: { id: pluginId, name: pluginId },
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: {},
|
||||
slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false })),
|
||||
hookDisposables: [],
|
||||
} as unknown as ActivePlugin);
|
||||
offered.add(pluginId);
|
||||
}
|
||||
|
||||
/** Removes every fake plugin registered with offerSlots. */
|
||||
export function clearOffers(): void {
|
||||
for (const id of offered) deregister(id);
|
||||
offered.clear();
|
||||
}
|
||||
|
||||
/** Stand-in for PluginIframeSlot: a div carrying the slot name and its props as JSON. */
|
||||
export function FakeIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record<string, unknown> }) {
|
||||
return React.createElement('div', {
|
||||
'data-testid': `slot-${slot}`,
|
||||
'data-plugin': pluginId,
|
||||
'data-props': JSON.stringify(extraProps ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The props the fake iframe of a slot received. */
|
||||
export function slotProps(el: HTMLElement): Record<string, unknown> {
|
||||
return JSON.parse(el.getAttribute('data-props') ?? '{}');
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function isMounted(name: string): boolean {
|
||||
// that finishes an area removes it here; the test then insists every
|
||||
// surface in it has a mount site.
|
||||
const NOT_YET_MOUNTED = new Set<SlotArea>([
|
||||
'app', 'navigation', 'mail-list', 'reader', 'composer', 'settings',
|
||||
'app', 'navigation', 'settings',
|
||||
]);
|
||||
|
||||
describe('slot catalogue', () => {
|
||||
|
||||
Reference in New Issue
Block a user