mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins add frames above and in an empty message list and buttons to message rows and the selection toolbar
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') ?? '{}');
|
||||
}
|
||||
Reference in New Issue
Block a user