Merge branch 'feat/assistant-everywhere' into feat/assistant-d

This commit is contained in:
Linus Rath
2026-11-03 01:01:29 +01:00
85 changed files with 2324 additions and 136 deletions
@@ -0,0 +1,153 @@
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { useTaskStore } from '@/stores/task-store';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
// The plugin side panel (app-side-panel) in Calendar: its button at the end
// of the toolbar row, the panel last in the row under it, and the event the
// user opened in its context.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const config = vi.hoisted(() => ({ demoMode: false }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
const lite = vi.hoisted(() => ({ on: false }));
vi.mock('@/lib/lite', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/lite')>()),
get IS_LITE() { return lite.on; },
}));
import { CalendarApp } from '../calendar-app';
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true },
participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed',
...overrides,
} as unknown as CalendarEvent;
}
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const store = {
fetchCalendars: vi.fn(),
fetchEvents: vi.fn(),
refreshAllSubscriptions: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
rsvpEvent: vi.fn(),
};
function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') {
for (const fn of Object.values(store)) fn.mockReset();
store.createEvent.mockImplementation(async (_c: unknown, data: Partial<CalendarEvent>) => ({ ...makeEvent(), ...data, id: 'new' }));
store.updateEvent.mockResolvedValue(true);
store.deleteEvent.mockResolvedValue(true);
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
useCalendarStore.setState({
calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode,
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
...store,
} as never);
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never);
useToastStore.setState({ toasts: [] } as never);
useTaskStore.setState({ tasks: [] } as never);
client.getCalendarTasks.mockResolvedValue([]);
}
beforeEach(() => {
media.mobile = false;
media.desktop = true;
localStorage.clear();
setUp();
});
afterEach(() => clearSlotOffers());
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
describe('Calendar side panel', () => {
it('has no button and no panel without a plugin', () => {
render(<CalendarApp />);
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
expect(frame()).toBeNull();
});
it('puts the button at the end of the toolbar row and the panel under it, with the open event', async () => {
offerSlots('p1', ['app-side-panel']);
render(<CalendarApp />);
const toggle = document.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
const row = screen.getByTestId('calendar-view-menu').closest('.h-14')!;
// The last control of the row: after the view menu.
const buttons = Array.from(row.querySelectorAll('button'));
expect(buttons[buttons.length - 1]).toBe(toggle);
expect(toggle).toHaveAccessibleName('plugin_panel');
expect(toggle).toHaveAttribute('aria-pressed', 'true');
// Open by default; the last thing in the row below the toolbar.
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(panel.parentElement!.getAttribute('data-tour')).toBe('calendar-view');
expect(framePropsOf(frame()!)).toEqual({ view: 'calendar', context: {}, focusRequest: 0 });
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
await screen.findByRole('dialog', { name: 'Standup' });
await waitFor(() => expect((framePropsOf(frame()!).context as { event?: { title: string } }).event?.title).toBe('Standup'));
fireEvent.click(toggle);
expect(frame()).toBeNull();
expect(toggle).toHaveAttribute('aria-pressed', 'false');
});
it('has no panel on a phone', () => {
media.mobile = true;
media.desktop = false;
offerSlots('p1', ['app-side-panel']);
act(() => { render(<CalendarApp />); });
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
expect(frame()).toBeNull();
});
});
+10
View File
@@ -113,6 +113,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { calendarHooks } from "@/lib/plugin-hooks";
import { eventToView } from "@/lib/plugin-projection";
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
import { AppMain, SkipLink } from "@/components/layout/app-landmarks";
import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
@@ -1751,6 +1752,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// event gets the primary ring).
const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null;
// The plugin side panel (app-side-panel), with the event the user opened.
const openEvent = detailPinned ? detailEvent : showEventModal ? editEvent : null;
const sidePanelContext = useMemo(() => (openEvent ? { event: eventToView(openEvent) } : {}), [openEvent]);
const sidePanel = useAppSidePanel({ view: "calendar", context: sidePanelContext });
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
@@ -2098,6 +2104,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
) : null}
onCloseSearch={leaveSearch}
panelToggle={sidePanel.toggle}
/>
);
@@ -2277,6 +2284,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
import dropdown, which overlaps this area (#1049). */}
<div
ref={sidePanel.rowRef}
className="flex flex-1 overflow-hidden relative isolate"
data-tour="calendar-view"
// Where the focus goes after an event is saved or deleted and
@@ -2353,6 +2361,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<Plus className="h-6 w-6" />
</Button>
)}
{sidePanel.panel}
</div>
</AppMain>
</div>
+6
View File
@@ -61,6 +61,8 @@ interface CalendarToolbarProps {
mobileSearchField?: ReactNode;
/** Phones: closes the search row. */
onCloseSearch?: () => void;
/** The plugin side panel's button, at the very end of the row (desktop and tablet). */
panelToggle?: ReactNode;
}
// Shortcut keys handled by the calendar app (see its keydown handler).
@@ -101,6 +103,7 @@ export function CalendarToolbar({
onOpenSearch,
mobileSearchField,
onCloseSearch,
panelToggle,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const paint = useCvdColor();
@@ -484,6 +487,9 @@ export function CalendarToolbar({
))}
</ContextMenu>
</div>
{/* The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6). */}
{panelToggle && <div className="flex items-center ms-1 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div>}
</div>
);
}
@@ -0,0 +1,132 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
// The plugin side panel (app-side-panel) in Contacts: a row of its own over
// the contact and the panel, with the open contact's name and the button at
// its end, and the contact in the panel's context.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const media = vi.hoisted(() => ({ mobile: false }));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useIsMobile: () => media.mobile,
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en/contacts',
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
}),
}));
import { ContactsApp } from '../contacts-app';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
const alice: ContactCard = {
id: 'c-alice',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
const bob: ContactCard = {
id: 'c-bob',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
};
function renderContacts() {
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: { supportsPrincipals: () => false } as unknown as IJMAPClient, isLoading: false });
useContactStore.setState({
contacts: [alice, bob], addressBooks: [], selectedContactId: null, selectedContactIds: new Set(),
searchQuery: '', supportsSync: false, contactsLoaded: true,
});
});
return render(<ContactsApp />);
}
afterEach(() => {
media.mobile = false;
setActionDispatcher(null);
clearSlotOffers();
localStorage.clear();
});
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
describe('Contacts side panel', () => {
it('adds no row without a plugin', () => {
renderContacts();
expect(document.querySelector('[data-contacts-panel-row]')).toBeNull();
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
});
it("puts the open contact's name and the button in an h-14 row over the contact and the panel", () => {
offerSlots('p1', ['app-side-panel']);
renderContacts();
const row = document.querySelector<HTMLElement>('[data-contacts-panel-row]')!;
expect(row.className).toContain('h-14');
const toggle = row.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
expect(row.lastElementChild).toBe(toggle);
expect(toggle).toHaveAccessibleName('plugin_panel');
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(framePropsOf(frame()!)).toEqual({ view: 'contacts', context: { selectedCount: 0 }, focusRequest: 0 });
// The panel is the last thing in the row under it.
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(row.nextElementSibling).toBe(panel.parentElement);
fireEvent.click(screen.getByText('Alice Smith'));
expect(row.firstElementChild).toHaveTextContent('Alice Smith');
expect(framePropsOf(frame()!).context).toMatchObject({ contact: { id: 'c-alice', fullName: 'Alice Smith', emails: ['alice@example.com'] }, selectedCount: 0 });
fireEvent.click(toggle);
expect(frame()).toBeNull();
// The row and its button stay, so the panel can come back.
expect(row.querySelector('[data-plugin-panel-toggle]')).toHaveAttribute('aria-pressed', 'false');
});
it('keeps what was typed in a contact form when the panel starts docking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
const field = screen.getByPlaceholderText('middle_name');
act(() => offerSlots('p1', ['app-side-panel']));
expect(document.querySelector('[data-contacts-panel-row]')).not.toBeNull();
// The same field, with what the user typed.
expect(field.isConnected).toBe(true);
expect(screen.getByPlaceholderText('middle_name')).toHaveValue('Marie');
});
it('puts the phone button over an open contact, where the list and its header are gone', () => {
media.mobile = true;
const dispatch = vi.fn().mockResolvedValue(undefined);
setActionDispatcher(dispatch);
offerSlots('p1', ['app-side-panel']);
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
const buttons = document.querySelectorAll<HTMLElement>('[data-app-side-panel-phone]');
expect(buttons).toHaveLength(1);
fireEvent.click(buttons[0]);
const [, surface, action, ctx] = dispatch.mock.calls[0];
expect([surface, action]).toEqual(['app-side-panel', 'open']);
expect((ctx as { context: { contact?: { fullName: string } } }).context.contact?.fullName).toBe('Alice Smith');
});
});
+35 -5
View File
@@ -23,6 +23,8 @@ import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-deta
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AppSidePanelPhoneButton, useAppSidePanel } from "@/components/plugins/app-side-panel";
import { contactToView } from "@/lib/plugin-projection";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -389,6 +391,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
// The plugin side panel (app-side-panel), with the contact the user opened
// and how many are ticked. On desktop it sits under a row of its own with
// the open contact's name and the panel's button.
const openContact = (view === "detail" || view === "edit") && activeCategory !== "directory" && selectedContact?.kind !== "group" ? selectedContact : null;
const selectedCount = selectedContactIds.size;
const sidePanelContext = useMemo(() => ({
...(openContact ? { contact: contactToView(openContact) } : {}),
selectedCount,
}), [openContact, selectedCount]);
const sidePanel = useAppSidePanel({ view: "contacts", context: sidePanelContext });
const sidePanelTitle = openContact
? getContactDisplayName(openContact)
: view === "group-detail" && selectedGroup ? getContactDisplayName(selectedGroup) : "";
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
@@ -1429,11 +1445,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
</>
)}
{/* Panel 3: Detail / Form */}
{/* Panel 3: Detail / Form. With a plugin side panel, a row with the
contact's name and the panel's button sits over it, like the
reader's toolbar. The same element either way, so a form keeps
what was typed when the panel starts or stops docking. */}
{showRightPanel && (
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-w-0 flex flex-col" data-contacts-side-panel-column="">
{sidePanel.docks && (
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-contacts-panel-row="">
<div className="flex-1 min-w-0 truncate text-sm font-semibold" title={sidePanelTitle || undefined}>{sidePanelTitle}</div>
{sidePanel.toggle}
</div>
)}
{isMobile && (
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<Button
variant="ghost"
size="sm"
@@ -1443,10 +1468,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
{/* The list's header with its button is hidden while a contact is open. */}
<AppSidePanelPhoneButton className="ms-auto" />
</div>
)}
<div className="flex-1 min-h-0">
{renderRightPanel()}
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
<div className="flex-1 min-w-0 min-h-0">
{renderRightPanel()}
</div>
{sidePanel.panel}
</div>
</div>
)}
@@ -113,7 +113,7 @@ describe('email-detail-sidebar', () => {
rerender(<EmailViewer email={textMail('Hi')} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
});
it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
@@ -121,7 +121,7 @@ describe('email-detail-sidebar', () => {
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
expect(panel.className).toContain('absolute');
@@ -168,7 +168,7 @@ describe('email-detail-sidebar', () => {
offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] });
const { unmount } = viewer(textMail('Hi'));
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked');
unmount();
viewer(textMail('Hi'));
@@ -63,4 +63,40 @@ describe('the reader panel prop listSelection', () => {
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b'), row('c')] }); });
expect(seen.at(-1)?.listSelection).toBe(before);
});
it('does not render the reader again when the list changes but the ticks do not', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />);
const renders = seen.length;
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b')] }); });
act(() => { useEmailStore.setState({ emails: [row('newer'), row('new'), row('a'), row('b')], threadEmailsCache: new Map() }); });
expect(seen.length).toBe(renders);
// A new tick does.
act(() => { useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b', 'new']) }); });
expect(seen.length).toBeGreaterThan(renders);
});
it('tells the panel when this login can not read the open message\'s parts', () => {
// An open message makes the viewer ask the login more; nothing of it matters here.
const client = new Proxy({ getAccountId: () => 'acc1' } as Record<string | symbol, unknown>, {
get: (target, key) => (key in target ? target[key] : key === 'then' ? undefined : () => undefined),
}) as unknown as IJMAPClient;
useAuthStore.setState({ client });
const message = { ...row('m'), textBody: [], htmlBody: [], attachments: [], bodyValues: {} } as unknown as Email;
const view = () => render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} blobSource={{ client: otherLogin, accountId: 'acc-2' }} />);
const otherLogin = { getAccountId: () => 'acc-2' } as unknown as IJMAPClient;
view();
expect((seen.at(-1)?.email as { blobsReadable?: boolean }).blobsReadable).toBe(false);
cleanup();
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} blobSource={{ client, accountId: undefined }} />);
expect(seen.at(-1)?.email).not.toHaveProperty('blobsReadable');
});
it('gives a panel outside the main list (a Pro tab) no ticks', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} withListSelection={false} />);
expect(seen.at(-1)).toMatchObject({ listSelection: null });
});
});
@@ -57,14 +57,14 @@ describe('plugin detail panel and narrow messages', () => {
renderViewer();
expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
});
it('opens anyway when the user asks', () => {
reportWidth = 640;
renderViewer();
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
act(() => { fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false })); });
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
});
@@ -61,7 +61,7 @@ const show = () => render(
/** The plugin panel, open or folded to its toggle. */
const sidePanel = () =>
(document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null)
?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement);
?? (screen.getByLabelText('plugin_panel').closest('div') as HTMLElement);
/** An item of the top toolbar. */
const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement;
@@ -139,12 +139,15 @@ describe('the reader toolbar and the plugin side panel', () => {
const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement;
// At the end of the toolbar, not in the panel.
expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull();
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide');
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
// A landmark for screen readers, named after its plugin.
expect(screen.getByRole('complementary')).toBe(document.querySelector('[data-plugin-detail-panel]'));
act(() => { fireEvent.click(toggle); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
// Folded, the panel leaves no strip of its own: the toolbar button brings it back.
expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1);
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
expect(screen.getAllByLabelText('plugin_panel')).toHaveLength(1);
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel')); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
@@ -154,12 +157,13 @@ describe('the reader toolbar and the plugin side panel', () => {
expect(row().hasAttribute('data-toolbar-toggle-only')).toBe(true);
// As high as the full toolbar.
expect(row().firstElementChild!.className).toContain('h-14');
expect(theToggle().getAttribute('title')).toBe('plugin_panel_hide');
expect(theToggle().getAttribute('title')).toBe('plugin_panel');
expect(theToggle().getAttribute('aria-pressed')).toBe('true');
act(() => { fireEvent.click(theToggle()); });
// Folded: no strip and no row of the panel's own, the same button in the same row.
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
expect(document.querySelector('[data-reader-side-panel]')!.children).toHaveLength(0);
expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel_show');
expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel');
expect(theToggle().getAttribute('aria-pressed')).toBe('false');
act(() => { fireEvent.click(theToggle()); });
// Open: the panel holds no button row above the plugin's own header.
+14 -16
View File
@@ -40,6 +40,7 @@ import { useBottomControls } from "@/hooks/use-bottom-controls";
import { useComposingSurface } from "@/hooks/use-composing-surface";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel";
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
@@ -98,7 +99,6 @@ import {
MapPin,
StickyNote,
PanelRightClose,
PanelRightOpen,
Send,
FolderInput,
Sun,
@@ -136,6 +136,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store";
import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection";
import { readability } from "@/lib/plugin-sandbox/assistant/b.projection";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { ExternalSenderChip } from "./external-sender-chip";
@@ -288,6 +289,8 @@ interface EmailViewerProps {
mailboxes?: Mailbox[];
selectedMailbox?: string;
className?: string;
/** The side panel hears of the messages ticked in the main list (false: a Pro tab, whose message isn't from that list). */
withListSelection?: boolean;
}
// Helper function to format recipients with contextual display
@@ -1313,6 +1316,7 @@ export function EmailViewer({
mailboxes = [],
selectedMailbox = "",
className,
withListSelection = true,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const archiveActionEnabled = useArchiveActionEnabled();
@@ -1801,9 +1805,11 @@ export function EmailViewer({
// each slot post its props to the plugin again and re-run shouldShow.
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
|| hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
// A message of another connected login (unified view, Pro tab): the host
// reads parts only through the signed-in login, so plugins hear they can't.
const pluginEmailView = useMemo(
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
[email, wantsPluginEmailView],
() => (email && wantsPluginEmailView ? { ...emailToReadView(email), ...readability(blobClient === client) } : null),
[email, wantsPluginEmailView, blobClient, client],
);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
@@ -1819,7 +1825,7 @@ export function EmailViewer({
}, []);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
// The messages ticked in the list, for the AI assistant (lib/plugin-sandbox/assistant).
const listSelection = useListSelection();
const listSelection = useListSelection(withListSelection);
const pluginDetailProps = useMemo(
() => ({ email: pluginEmailView, selection: bodySelection, listSelection, focusRequest: detailFocusRequest }),
[pluginEmailView, bodySelection, listSelection, detailFocusRequest],
@@ -1888,19 +1894,9 @@ export function EmailViewer({
};
// The one button that shows and hides the plugin side panel, at the end of
// the toolbar row in every state of the reader (message, loading, none, scheduled).
// The same button as in the other views' side panels (app-side-panel.tsx).
const panelToggle = hasDetailSidebar && !isMobile ? (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8 shrink-0", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
<PluginPanelToggle open={!sidebarCollapsed} onClick={toggleDetailSidebar} className={cn(mvViewer && "order-10")} shortcut={panelShortcutOf(detailSlots.map((o) => o.pluginId))} />
) : null;
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'email-detail-sidebar') return null;
@@ -3911,6 +3907,8 @@ ${printWatermark ? printWatermark.html : ''}
/>
)}
<div
role="complementary"
aria-label={panelLabelOf(detailSlots.map((o) => o.pluginId), t('plugin_panel'))}
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { FileBrowser } from '../file-browser';
import { useFileStore, type FileResource } from '@/stores/file-store';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The plugin side panel (app-side-panel) in Files: its button right after
// the details button, at the end of the header in both of its shapes, the
// panel last in the row under it with the selected files and the folder,
// and the details panel and it taking turns.
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const device = vi.hoisted(() => ({ isMobile: false }));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useIsMobile: () => device.isMobile,
}));
const folder = {
id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0,
lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
} as FileResource;
const notes = {
id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10,
lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
} as FileResource;
const photo = {
id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99,
lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null,
} as FileResource;
function renderBrowser(selected: string[] = []) {
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
act(() => {
useFileStore.setState({
client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true,
});
});
const noop = vi.fn(async () => {});
render(
<FileBrowser
currentPath="/"
resources={[folder, notes, photo]}
isLoading={false}
error={null}
selectedResources={new Set(selected)}
uploadProgress={null}
clipboard={null}
onNavigate={vi.fn()}
onSetSelection={vi.fn()}
onToggleSelect={vi.fn()}
onPreviewFile={vi.fn()}
onDownload={noop}
onCreateFolder={noop}
onUploadFiles={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onBatchDownload={noop}
onRefresh={noop}
onClearSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveTo={noop}
onPreviewImage={vi.fn()}
onCreateFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
favorites={[]}
recentFiles={[]}
onToggleFavorite={vi.fn()}
onOpenRecent={vi.fn()}
onRenameNode={noop}
onDeleteNode={noop}
onDownloadNode={noop}
onRevealNode={noop}
onRemoveRecent={vi.fn()}
client={client}
ownAccountId="a"
/>,
);
}
beforeEach(() => {
device.isMobile = false;
localStorage.clear();
});
afterEach(() => {
cleanup();
clearSlotOffers();
});
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
const toggle = () => document.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
const details = () => screen.getByRole('button', { name: 'details' });
describe('Files side panel', () => {
it('has no button without a plugin', () => {
renderBrowser();
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
});
it('puts the button right after the details button, last in the header, and the folder in the context', () => {
offerSlots('p1', ['app-side-panel']);
renderBrowser();
const header = details().closest('.h-14')!;
const buttons: HTMLElement[] = Array.from(header.querySelectorAll('button'));
expect(buttons.indexOf(toggle())).toBe(buttons.indexOf(details()) + 1);
expect(buttons[buttons.length - 1]).toBe(toggle());
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(framePropsOf(frame()!)).toEqual({ view: 'files', context: { folder: { id: null, path: '/' } }, focusRequest: 0 });
});
it('names the selected files, and keeps the button last in the selection bar', () => {
offerSlots('p1', ['app-side-panel']);
renderBrowser(['notes.txt', 'photo.png']);
const bar = screen.getByRole('toolbar', { name: 'selection_actions' });
const buttons = Array.from(bar.querySelectorAll('button'));
expect(buttons[buttons.length - 1]).toBe(toggle());
expect(buttons[buttons.length - 2]).toBe(details());
const context = framePropsOf(frame()!).context as { files: { name: string }[]; fileCount: number };
expect(context.files.map((f) => f.name)).toEqual(['notes.txt', 'photo.png']);
expect(context.fileCount).toBe(2);
});
it('folds the details when the panel opens, and the panel when the details open', () => {
offerSlots('p1', ['app-side-panel']);
localStorage.setItem('appSidePanelCollapsed', '1');
renderBrowser();
expect(frame()).toBeNull();
fireEvent.click(details());
expect(details()).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle());
expect(frame()).not.toBeNull();
expect(details()).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(details());
expect(details()).toHaveAttribute('aria-pressed', 'true');
expect(frame()).toBeNull();
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
// Only here: the other views keep the panel open as the user left it.
expect(localStorage.getItem('appSidePanelCollapsed')).toBe('0');
// The details go, the panel is back.
fireEvent.click(details());
expect(frame()).not.toBeNull();
});
it('never shows both: with both stored open the details fold when the page loads', () => {
offerSlots('p1', ['app-side-panel']);
localStorage.setItem('appSidePanelCollapsed', '0');
localStorage.setItem('files-show-details', '1');
renderBrowser();
expect(frame()).not.toBeNull();
expect(details()).toHaveAttribute('aria-pressed', 'false');
expect(document.querySelector('[data-app-side-panel]')).toBeTruthy();
});
it('has no button in the header on a phone', () => {
device.isMobile = true;
offerSlots('p1', ['app-side-panel']);
act(() => renderBrowser());
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
expect(frame()).toBeNull();
});
});
+25 -1
View File
@@ -32,6 +32,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { AppMain } from "@/components/layout/app-landmarks";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
@@ -438,6 +440,26 @@ export function FileBrowser({
// What plugins see of the selection (file-actions, file-preview-section).
const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
// The plugin side panel (app-side-panel), with the selected files and the
// open folder. It shares its place with the details panel: opening it
// folds the details, and while the details are open it steps aside here
// only (the other views keep it as the user left it).
const sidePanelContext = useMemo(() => ({
...(selectedViews.length > 0 ? { files: selectedViews.slice(0, MAX_SIDE_PANEL_FILES), fileCount: selectedViews.length } : {}),
folder: { id: currentParentId ?? null, path: currentPath },
}), [selectedViews, currentParentId, currentPath]);
const sidePanel = useAppSidePanel({ view: "files", context: sidePanelContext, hidden: showDetails, onOpen: () => toggleDetails(false) });
// Both stored open (the details here, the panel in any view): once the
// panel shows here the details fold, so the two never sit side by side.
const sidePanelDocks = sidePanel.docks;
const sidePanelWanted = sidePanel.wantsOpen;
const sidePanelSettled = useRef(false);
useEffect(() => {
if (sidePanelSettled.current || !sidePanelDocks) return;
sidePanelSettled.current = true;
if (showDetails && sidePanelWanted) toggleDetails(false);
}, [sidePanelDocks, sidePanelWanted, showDetails, toggleDetails]);
// ---- Breadcrumbs -----------------------------------------------------------
const crumbs: Crumb[] = useMemo(() => {
const segments = currentPath.split("/").filter(Boolean);
@@ -1282,6 +1304,7 @@ export function FileBrowser({
toolsHidden={accountPickerMode}
selection={selectionActions}
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
panelToggle={sidePanel.toggle}
/>
{error && (
@@ -1295,7 +1318,7 @@ export function FileBrowser({
</div>
)}
<div className="relative flex flex-1 min-h-0">
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
<div
ref={scrollAreaRef}
className="relative flex-1 min-w-0 overflow-y-auto"
@@ -1356,6 +1379,7 @@ export function FileBrowser({
</Button>
)}
{sidePanel.panel}
</div>
</AppMain>
+8 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
@@ -84,6 +84,8 @@ interface FilesHeaderProps {
selection: SelectionActions | null;
/** In the trash: the Empty trash button. */
emptyTrash?: { onEmpty: () => void; disabled: boolean };
/** The plugin side panel's button, after the details button (desktop and tablet). */
panelToggle?: ReactNode;
}
const MAX_VISIBLE_CRUMBS = 3;
@@ -114,6 +116,7 @@ export function FilesHeader({
toolsHidden,
selection,
emptyTrash,
panelToggle,
}: FilesHeaderProps) {
const t = useTranslations("files");
const sortMenu = useContextMenu<null>();
@@ -122,6 +125,8 @@ export function FilesHeader({
const [dropCrumb, setDropCrumb] = useState<string | null>(null);
const headerClass = "flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0";
// The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6).
const toggleSlot = panelToggle ? <div className="flex items-center shrink-0 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div> : null;
if (selection) {
return (
@@ -169,6 +174,7 @@ export function FilesHeader({
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{toggleSlot}
</div>
);
}
@@ -360,6 +366,7 @@ export function FilesHeader({
</ToolbarButton>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{toggleSlot}
<PhoneHeaderActions
below="md"
menuItems={(close) => (
@@ -7,6 +7,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { AppSidePanelPhoneButton } from "@/components/plugins/app-side-panel";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { openCommandPalette } from "@/lib/app-commands";
@@ -140,6 +141,8 @@ export function PhoneHeaderActions({
return (
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
{/* A plugin's side panel of this view, which opens as a dialog on a phone. */}
<AppSidePanelPhoneButton />
<PhoneHeaderMenu menuItems={menuItems} />
{/* Which account you are in is otherwise only visible in Settings.
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
@@ -0,0 +1,287 @@
import React from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearOffers, FakeIframeSlot, offerSlots, slotProps } from './fake-plugin-offer';
import { requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import { registerShortcuts } from '@/lib/plugin-sandbox/shortcuts';
// The plugin side panel of Calendar, Contacts, Files and Settings
// (app-side-panel): its button, folding, width, the overlay on a narrow
// view, focus requests, the plugin's own requests, and the dialog where it
// can't dock.
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: FakeIframeSlot }));
const device = vi.hoisted(() => ({ isMobile: false }));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useIsMobile: () => device.isMobile,
}));
const { useAppSidePanel, AppSidePanelPhoneButton, APP_SIDE_PANEL_COLLAPSED_KEY, APP_SIDE_PANEL_WIDTH_KEY } = await import('../app-side-panel');
const { EmbeddedContext } = await import('@/hooks/use-is-embedded');
import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
// One ResizeObserver per row; tests set the row's width with resizeRow().
let observed: ((width: number) => void) | null = null;
class FakeResizeObserver {
constructor(private cb: ResizeObserverCallback) {}
observe() { observed = (width) => this.cb([{ contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver); }
disconnect() { observed = null; }
unobserve() {}
}
const resizeRow = (width: number) => act(() => observed?.(width));
const EVENT_CONTEXT: AppSidePanelContext = { page: { id: 'reading', label: 'Reading' } };
function Harness({ view = 'settings', context = EVENT_CONTEXT, dock = true, hidden = false, onOpen }: { view?: AppSidePanelViewName; context?: AppSidePanelContext; dock?: boolean; hidden?: boolean; onOpen?: () => void }) {
const panel = useAppSidePanel({ view, context, dock, hidden, onOpen });
return (
<div>
<div data-testid="toolbar">{panel.toggle}</div>
<div ref={panel.rowRef} data-testid="row">{panel.panel}</div>
<div data-testid="phone-header"><AppSidePanelPhoneButton /></div>
</div>
);
}
const toggle = () => screen.getByRole('button', { name: 'plugin_panel' });
const frame = () => screen.queryByTestId('slot-app-side-panel');
beforeEach(() => {
device.isMobile = false;
localStorage.clear();
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
});
afterEach(() => {
cleanup();
clearOffers();
resetPanelRequests();
setActionDispatcher(null);
vi.unstubAllGlobals();
});
describe('app-side-panel', () => {
it('shows nothing without a plugin offering it', () => {
render(<Harness />);
expect(screen.queryByRole('button')).toBeNull();
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
});
it('opens and folds from its button, and the frame gets the view, its context and a focus request', () => {
offerSlots('p1', ['app-side-panel'], { fill: ['app-side-panel'] });
render(<Harness />);
// Open until the user folds it (the plugin didn't ask to start folded).
expect(frame()).not.toBeNull();
expect(toggle()).toHaveAttribute('aria-pressed', 'true');
// One name; aria-pressed says whether it is open.
expect(toggle()).toHaveAttribute('title', 'plugin_panel');
expect(screen.getByRole('complementary', { name: 'p1' })).toContainElement(frame());
expect(slotProps(frame()!)).toEqual({ view: 'settings', context: EVENT_CONTEXT, focusRequest: 0 });
expect(frame()!.getAttribute('data-fill')).toBe('true');
fireEvent.click(toggle());
expect(frame()).toBeNull();
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
expect(toggle()).toHaveAccessibleName('plugin_panel');
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('1');
// Opening asks the plugin to take the focus.
fireEvent.click(toggle());
expect(slotProps(frame()!).focusRequest).toBe(1);
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('0');
});
it('starts folded when the plugin asks, until the user opens it', () => {
offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
const { unmount } = render(<Harness />);
expect(frame()).toBeNull();
fireEvent.click(toggle());
expect(frame()).not.toBeNull();
unmount();
// The choice holds in the next view.
render(<Harness view="calendar" context={{}} />);
expect(frame()).not.toBeNull();
});
it('keeps its width between 200 and 500 px, and remembers it', () => {
offerSlots('p1', ['app-side-panel']);
localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, '900');
render(<Harness />);
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.style.width).toBe('500px');
const handle = screen.getByRole('separator');
expect(handle).toHaveAttribute('aria-valuemin', '200');
expect(handle).toHaveAttribute('aria-valuemax', '500');
fireEvent.doubleClick(handle);
expect(panel.style.width).toBe('280px');
expect(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY)).toBe('280');
});
it('folds when the view gets too narrow, and lies over it when opened anyway', () => {
offerSlots('p1', ['app-side-panel']);
render(<Harness />);
resizeRow(1200);
expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
resizeRow(600);
expect(frame()).toBeNull();
fireEvent.click(toggle());
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel).toHaveAttribute('data-app-side-panel', 'overlay');
expect(panel.className).toContain('absolute');
// No drag handle over the view.
expect(screen.queryByRole('separator')).toBeNull();
});
it('answers the plugin\'s open, close and toggle requests', () => {
offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
const onOpen = vi.fn();
render(<Harness onOpen={onOpen} />);
expect(frame()).toBeNull();
let answer: boolean | null = null;
act(() => { answer = requestPanel('p1', 'app-side-panel', 'open', undefined); });
expect(answer).toBe(true);
expect(frame()).not.toBeNull();
expect(slotProps(frame()!).focusRequest).toBe(1);
expect(onOpen).toHaveBeenCalledTimes(1);
act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
expect(answer).toBe(false);
expect(frame()).toBeNull();
act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
expect(answer).toBe(false);
// The reader's panel is not this one.
act(() => { answer = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
expect(answer).toBeNull();
});
it('is not there in the Pro shell\'s panes', () => {
offerSlots('p1', ['app-side-panel']);
render(<EmbeddedContext.Provider value><Harness /></EmbeddedContext.Provider>);
expect(screen.queryByRole('button')).toBeNull();
expect(requestPanel('p1', 'app-side-panel', 'open', undefined)).toBeNull();
});
it('asks the plugin for its dialog on a phone, with the view and what it shows', async () => {
device.isMobile = true;
const dispatch = vi.fn().mockResolvedValue(undefined);
setActionDispatcher(dispatch);
offerSlots('p1', ['app-side-panel']);
render(<Harness view="calendar" context={{ selectedCount: 2 }} />);
// No panel and no toolbar button; the phone header has the button.
expect(screen.queryByTestId('toolbar')!.childElementCount).toBe(0);
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
const phone = screen.getByRole('button', { name: 'plugin_panel_show' });
expect(screen.getByTestId('phone-header')).toContainElement(phone);
fireEvent.click(phone);
expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'calendar', context: { selectedCount: 2 } });
// The plugin's own open request goes the same way; the panel stays closed.
let answer: boolean | null = null;
act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
expect(answer).toBe(false);
expect(dispatch).toHaveBeenCalledTimes(2);
act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
expect(dispatch).toHaveBeenCalledTimes(2);
});
it('uses the dialog where the view says it has no room (Settings below 1024 px)', () => {
const dispatch = vi.fn().mockResolvedValue(undefined);
setActionDispatcher(dispatch);
offerSlots('p1', ['app-side-panel']);
const { unmount } = render(<Harness dock={false} />);
expect(screen.getByTestId('toolbar').childElementCount).toBe(0);
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'settings', context: EVENT_CONTEXT });
// Leaving the view takes the phone header's button away.
unmount();
render(<div><AppSidePanelPhoneButton /></div>);
expect(screen.queryByRole('button')).toBeNull();
});
it("shows no button and no panel when the plugin's shouldShow says no", async () => {
const evaluateShouldShow = vi.fn(async () => false);
register({
plugin: { id: 'p2', name: 'P2', permissions: ['ui:side-panel'], managed: true },
code: '', tier: 'untrusted', background: { evaluateShouldShow },
slotOffers: [{ name: 'app-side-panel', order: 100, hasShouldShow: true }], hookDisposables: [],
} as unknown as ActivePlugin);
try {
render(<Harness view="calendar" context={{ selectedCount: 1 }} />);
await waitFor(() => expect(evaluateShouldShow).toHaveBeenCalledWith('app-side-panel', { view: 'calendar', context: {} }));
expect(screen.queryByRole('button')).toBeNull();
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
expect(requestPanel('p2', 'app-side-panel', 'open', undefined)).toBeNull();
// It says yes: the button comes.
evaluateShouldShow.mockResolvedValue(true);
cleanup();
render(<Harness view="calendar" context={{ selectedCount: 2 }} />);
expect(await screen.findByRole('button', { name: 'plugin_panel' })).toBeTruthy();
} finally {
deregister('p2');
}
});
it('steps aside while the view hides it, without changing the choice the other views keep', () => {
offerSlots('p1', ['app-side-panel']);
const onOpen = vi.fn();
const { rerender } = render(<Harness hidden onOpen={onOpen} />);
expect(frame()).toBeNull();
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBeNull();
rerender(<Harness hidden={false} onOpen={onOpen} />);
expect(frame()).not.toBeNull();
// Opening it while hidden asks the view to make room.
rerender(<Harness hidden onOpen={onOpen} />);
fireEvent.click(toggle());
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('grows only as far as the view keeps its room, so resizing never folds it', () => {
offerSlots('p1', ['app-side-panel']);
render(<Harness />);
resizeRow(900);
const handle = screen.getByRole('separator');
expect(handle).toHaveAttribute('aria-valuemax', '420');
handle.focus();
for (let i = 0; i < 30; i++) fireEvent.keyDown(handle, { key: 'ArrowLeft' });
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel).toHaveAttribute('data-app-side-panel', 'docked');
expect(panel.style.width).toBe('420px');
// The handle is still there and keeps the focus.
expect(document.activeElement).toBe(handle);
// A drag stops at the same place.
fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1, button: 0 });
fireEvent.pointerMove(document, { clientX: 100, pointerId: 1 });
fireEvent.pointerUp(document, { clientX: 100, pointerId: 1 });
expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
expect(document.querySelector<HTMLElement>('[data-app-side-panel]')!.style.width).toBe('420px');
});
it('gives the focus back to its button when it closes with the focus inside', async () => {
offerSlots('p1', ['app-side-panel']);
render(<Harness />);
const panel = screen.getByRole('complementary');
panel.tabIndex = -1;
panel.focus();
act(() => { requestPanel('p1', 'app-side-panel', 'close', undefined); });
await waitFor(() => expect(document.activeElement).toBe(toggle()));
});
it("names the plugin's panel shortcut in the button's title", () => {
offerSlots('p1', ['app-side-panel']);
const dispose = registerShortcuts({ pluginId: 'p1', invokeHook: vi.fn() } as never, [{ id: 'toggle-panel', keys: 'Alt+A', label: 'Toggle' }]);
try {
render(<Harness />);
expect(toggle()).toHaveAttribute('title', 'plugin_panel (Alt+A)');
expect(toggle()).toHaveAttribute('aria-keyshortcuts', 'Alt+A');
expect(toggle()).toHaveAccessibleName('plugin_panel');
} finally {
dispose();
}
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, render, screen } from '@testing-library/react';
// A plugin dialog that asks to be filled (api.ui.openDialog({ fill: true })):
// its frame takes the dialog's height, which follows what the window shows,
// so a chat's input stays in view on a phone with the keyboard up.
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('../plugin-iframe-slot', () => ({
PluginIframeSlot: ({ fill }: { fill?: boolean }) => <div data-testid="custom-frame" data-fill={fill ? 'true' : 'false'} />,
}));
import { PluginDialogHost } from '../plugin-dialog-host';
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
let listeners: (() => void)[] = [];
const viewport = { height: 667, addEventListener: (_: string, fn: () => void) => { listeners.push(fn); }, removeEventListener: () => {} };
beforeEach(() => {
while (head()) resolveHead(null);
resetDialogLimits();
listeners = [];
vi.stubGlobal('visualViewport', viewport);
viewport.height = 667;
});
afterEach(() => vi.unstubAllGlobals());
const box = () => document.querySelector<HTMLElement>('[data-plugin-dialog]')!;
describe('a plugin dialog that fills', () => {
it('fills the dialog, which fits the visible window and follows the keyboard', () => {
expect(isFillableSlot('plugin-dialog')).toBe(true);
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog', fill: true }); });
expect(screen.getByTestId('custom-frame').dataset.fill).toBe('true');
// 667 px window: 90 % less the margin, at most 640.
expect(box().style.height).toBe('584px');
// The keyboard comes up: 360 px left.
act(() => { viewport.height = 360; for (const fn of listeners) fn(); });
expect(box().style.height).toBe('308px');
act(() => resolveHead(null));
});
it('grows with its content as before without fill', () => {
render(<PluginDialogHost />);
act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog' }); });
expect(screen.getByTestId('custom-frame').dataset.fill).toBe('false');
expect(box().style.height).toBe('');
expect(box().style.maxHeight).toBe('90vh');
act(() => resolveHead(null));
});
it('is what api.ui.openDialog asks for with fill: true', async () => {
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
const plugin = { id: 'ai', name: 'AI', permissions: [], managed: true } as never;
void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant', fill: true }]);
await vi.waitFor(() => expect(head()?.fill).toBe(true));
resolveHead(null);
void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant' }]);
await vi.waitFor(() => expect(head()).toBeTruthy());
expect(head()?.fill).toBeUndefined();
resolveHead(null);
}, 30_000);
});
+367
View File
@@ -0,0 +1,367 @@
'use client';
// The plugin side panel of Calendar, Contacts, Files and Settings (slot
// app-side-panel), built like the reader's (email-viewer.tsx): one button at
// the end of the view's h-14 toolbar row shows and hides it, it can be
// dragged between 200 and 500 px, and it lies over the view instead of
// squeezing it when the view would get too narrow. Its width and whether it
// is folded are kept in localStorage for all four views. Until the user
// folds or opens it, a plugin that asked to start folded starts folded.
// Only a plugin whose shouldShow says yes gets the button and the panel.
//
// Each view mounts its own panel, so the frame starts over when the user
// switches views; the plugin keeps what it needs in its storage. In the Pro
// shell's panes there is none (only a Mail pane's reader panel).
//
// Phones, and Settings below 1024 px (phone-shaped there), have no room for
// a panel: a button in the phone header (PhoneHeaderActions, and Contacts'
// row over an open contact) asks the plugin for its dialog instead, through
// its onAction hook (surface `app-side-panel`, actionId `open`, context
// { view, context }), and so does api.ui.openPanel there.
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { PanelRightClose, PanelRightOpen } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useIsMobile } from '@/hooks/use-media-query';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
import { shortcutKeysFor } from '@/lib/plugin-sandbox/shortcuts';
import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
import { cn } from '@/lib/utils';
export const APP_SIDE_PANEL_SLOT = 'app-side-panel';
export const APP_SIDE_PANEL_MIN_WIDTH = 200;
export const APP_SIDE_PANEL_MAX_WIDTH = 500;
export const APP_SIDE_PANEL_DEFAULT_WIDTH = 280;
/** The view keeps at least this much room next to a docked panel; with less the panel lies over it. */
export const APP_SIDE_PANEL_MIN_CONTENT = 480;
export const APP_SIDE_PANEL_COLLAPSED_KEY = 'appSidePanelCollapsed';
export const APP_SIDE_PANEL_WIDTH_KEY = 'appSidePanelWidth';
/** The id of a plugin shortcut that opens and closes its panel; its keys go into the button's title. */
export const PANEL_SHORTCUT_ID = 'toggle-panel';
const clampWidth = (n: number) => Math.max(APP_SIDE_PANEL_MIN_WIDTH, Math.min(APP_SIDE_PANEL_MAX_WIDTH, n));
/**
* The button that shows and hides a plugin side panel, the same in the
* reader and in every other view: at the end of the toolbar row above the
* panel. One name ("Plugin panel"); aria-pressed says whether it is open.
* `shortcut`: the keys of the plugin's own shortcut for its panel.
*/
export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcut }: {
open: boolean;
onClick: () => void;
className?: string;
buttonRef?: React.Ref<HTMLButtonElement>;
shortcut?: string | null;
}) {
const t = useTranslations('email_viewer');
const label = t('plugin_panel');
return (
<Button
ref={buttonRef}
variant="ghost"
size="icon"
onClick={onClick}
className={cn('h-8 w-8 shrink-0', className)}
title={shortcut ? `${label} (${shortcut})` : label}
aria-label={label}
aria-pressed={open}
aria-keyshortcuts={shortcut ?? undefined}
data-plugin-panel-toggle=""
>
{open ? <PanelRightClose className="w-4 h-4 text-muted-foreground" /> : <PanelRightOpen className="w-4 h-4 text-muted-foreground" />}
</Button>
);
}
/** The keys of the panel shortcut of the first of these plugins that has one ("Alt+A"), or null. */
export function panelShortcutOf(pluginIds: string[]): string | null {
for (const id of pluginIds) {
const keys = shortcutKeysFor(id, PANEL_SHORTCUT_ID);
if (keys) return keys;
}
return null;
}
/** The panel's name for screen readers: its plugins' names, else "Plugin panel". */
export function panelLabelOf(pluginIds: string[], fallback: string): string {
const names = pluginIds.map((id) => getActivePlugin(id)?.plugin.name).filter((n): n is string => !!n);
return names.length ? names.join(', ') : fallback;
}
// ─── Where the panel can't dock: the plugin's dialog ───────────────
export interface AppSidePanelDialogEntry {
pluginId: string;
view: AppSidePanelViewName;
context: AppSidePanelContext;
}
let dialogEntry: AppSidePanelDialogEntry | null = null;
const dialogListeners = new Set<() => void>();
function setDialogEntry(entry: AppSidePanelDialogEntry | null) {
dialogEntry = entry;
for (const listener of dialogListeners) listener();
}
function subscribeDialogEntry(listener: () => void) {
dialogListeners.add(listener);
return () => { dialogListeners.delete(listener); };
}
/** The view whose plugin panel opens as a dialog right now (a phone), or null. */
export function useAppSidePanelDialogEntry(): AppSidePanelDialogEntry | null {
return useSyncExternalStore(subscribeDialogEntry, () => dialogEntry, () => null);
}
/** Asks the plugin for its dialog with what the view shows (the context is cut to its permissions on the way). */
export function openAppSidePanelDialog(entry: AppSidePanelDialogEntry, pluginId = entry.pluginId): void {
void runPluginAction(pluginId, APP_SIDE_PANEL_SLOT, 'open', { view: entry.view, context: entry.context }).catch(() => {});
}
/** The phone's button for the view's plugin panel, which opens as a dialog there. */
export function AppSidePanelPhoneButton({ className }: { className?: string } = {}) {
const t = useTranslations('email_viewer');
const entry = useAppSidePanelDialogEntry();
if (!entry) return null;
return (
<IconButton
size="icon-lg"
label={t('plugin_panel_show')}
onClick={() => openAppSidePanelDialog(entry)}
className={className}
data-app-side-panel-phone=""
>
<PanelRightOpen />
</IconButton>
);
}
// ─── Which plugins show it ─────────────────────────────────────────
/**
* The plugins whose offer of the panel shows here: each one's shouldShow
* (asked in its background frame with the props it may see) said yes, or it
* has none. Until a plugin answers its offer doesn't count, so a plugin that
* is switched off for these views never gets a button that opens nothing.
*/
function useShownPluginIds(pluginOffers: readonly { pluginId: string; hasShouldShow: boolean }[], view: AppSidePanelViewName, context: AppSidePanelContext): string[] {
const [answers, setAnswers] = useState<Record<string, boolean>>({});
const key = pluginOffers.map((o) => `${o.pluginId}:${o.hasShouldShow ? 1 : 0}`).join(',');
useEffect(() => {
let live = true;
for (const offer of pluginOffers) {
const active = getActivePlugin(offer.pluginId);
if (!offer.hasShouldShow || !active?.background?.evaluateShouldShow) {
setAnswers((prev) => (prev[offer.pluginId] === true ? prev : { ...prev, [offer.pluginId]: true }));
continue;
}
const props = slotPropsFor(active.plugin, APP_SIDE_PANEL_SLOT, { view, context });
active.background.evaluateShouldShow(APP_SIDE_PANEL_SLOT, props)
.then((show) => { if (live) setAnswers((prev) => (prev[offer.pluginId] === show ? prev : { ...prev, [offer.pluginId]: show })); })
.catch(() => { if (live) setAnswers((prev) => ({ ...prev, [offer.pluginId]: false })); });
}
return () => { live = false; };
// The offers by their key; a new context asks again (the plugin may decide by it).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, view, context]);
return useMemo(() => pluginOffers.filter((o) => answers[o.pluginId] === true).map((o) => o.pluginId), [pluginOffers, answers]);
}
// ─── The panel ─────────────────────────────────────────────────────
export interface UseAppSidePanelOptions {
view: AppSidePanelViewName;
/** What the view shows; keep it memoised, a new object is sent to the plugin. */
context: AppSidePanelContext;
/** False where the view has no room for a panel at any width it is in now (Settings below 1024 px): the dialog then. */
dock?: boolean;
/**
* Folded here for now without changing the stored choice (Files while its
* details panel is open), so the other views keep the panel as the user left it.
*/
hidden?: boolean;
/** Called when the panel opens, so the view can fold a panel it shares the place with (Files' details). */
onOpen?: () => void;
}
export interface AppSidePanel {
/** A plugin shows the panel here (outside the Pro shell's panes). */
offered: boolean;
/** The panel docks in this view now (else it is a dialog, or nothing). */
docks: boolean;
/** Whether the docked panel is open. */
open: boolean;
/** Whether the user's choice is to have it open (also while `hidden` folds it here). */
wantsOpen: boolean;
/** The button for the end of the toolbar row, or null. */
toggle: ReactNode;
/** The resize handle and the panel, as the last children of the row it sits in; null while folded. */
panel: ReactNode;
/** Put on the row the panel sits in: its width decides docked or over the view. */
rowRef: (el: HTMLElement | null) => void;
/** Folds the panel and keeps it folded. */
close: () => void;
}
function readChoice(): boolean | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY);
return stored === '1' ? true : stored === '0' ? false : null;
} catch { return null; }
}
function readWidth(): number {
if (typeof window === 'undefined') return APP_SIDE_PANEL_DEFAULT_WIDTH;
try {
const n = parseInt(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY) ?? '', 10);
return Number.isFinite(n) ? clampWidth(n) : APP_SIDE_PANEL_DEFAULT_WIDTH;
} catch { return APP_SIDE_PANEL_DEFAULT_WIDTH; }
}
export function useAppSidePanel({ view, context, dock = true, hidden = false, onOpen }: UseAppSidePanelOptions): AppSidePanel {
const t = useTranslations('email_viewer');
const isEmbedded = useIsEmbedded();
const isMobile = useIsMobile();
const offers = usePluginSlotOffers(APP_SIDE_PANEL_SLOT);
const shownIds = useShownPluginIds(isEmbedded ? [] : offers, view, context);
const offered = shownIds.length > 0 && !isEmbedded;
const docks = offered && dock && !isMobile;
const asDialog = offered && !docks;
const [choice, setChoice] = useState<boolean | null>(readChoice);
const [width, setWidth] = useState<number>(readWidth);
const widthAtStart = useRef(width);
const [forcedOpen, setForcedOpen] = useState(false);
const [focusRequest, setFocusRequest] = useState(0);
const [rowEl, setRowEl] = useState<HTMLElement | null>(null);
const [rowWidth, setRowWidth] = useState(0);
const toggleRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!docks || !rowEl || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => setRowWidth(Math.round(entries[0]?.contentRect.width ?? 0)));
ro.observe(rowEl);
return () => ro.disconnect();
}, [docks, rowEl]);
const collapsed = choice ?? offers.some((o) => o.startFolded && shownIds.includes(o.pluginId));
const tooNarrow = rowWidth > 0 && rowWidth - width < APP_SIDE_PANEL_MIN_CONTENT;
const folded = collapsed || hidden || (tooNarrow && !forcedOpen);
const overlay = !folded && tooNarrow;
// Docked, the panel grows only as far as the view keeps its room: a drag
// or the arrow keys stop there instead of folding the panel away.
const dockedMax = rowWidth > 0 ? Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - APP_SIDE_PANEL_MIN_CONTENT) : APP_SIDE_PANEL_MAX_WIDTH;
useEffect(() => {
if (choice === null) return;
try { localStorage.setItem(APP_SIDE_PANEL_COLLAPSED_KEY, choice ? '1' : '0'); } catch { /* ignore */ }
}, [choice]);
useEffect(() => {
try { localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, String(width)); } catch { /* ignore */ }
}, [width]);
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
const state = useRef({ offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null });
state.current = { offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null };
const setOpen = useCallback((want: boolean) => {
const now = state.current;
if (want) {
setChoice(false);
if (now.tooNarrow) setForcedOpen(true);
// Opening on request puts the focus in the panel (the plugin's input).
setFocusRequest((n) => n + 1);
onOpenRef.current?.();
} else {
// Focus in the panel goes back to the button, not to the page's start.
const hadFocus = !!panelRef.current && panelRef.current.contains(document.activeElement);
setChoice(true);
setForcedOpen(false);
if (hadFocus) requestAnimationFrame(() => toggleRef.current?.focus());
}
}, []);
const close = useCallback(() => {
if (!state.current.folded) setOpen(false);
}, [setOpen]);
// api.ui.openPanel / closePanel / togglePanel for this slot.
useEffect(() => onPanelRequest((request) => {
if (request.slot !== APP_SIDE_PANEL_SLOT) return null;
const now = state.current;
if (!now.offered) return null;
if (!now.docks) {
// No room for a panel: the plugin that asked gets the open request
// for its dialog, and the panel itself stays closed.
if (request.mode !== 'close') openAppSidePanelDialog({ pluginId: request.pluginId, view: now.view, context: now.context });
return false;
}
const want = request.mode === 'open' ? true : request.mode === 'close' ? false : now.folded;
setOpen(want);
return want;
}), [setOpen]);
// The phone button, while this view's panel is a dialog.
const firstPluginId = shownIds[0] ?? null;
useEffect(() => {
if (!asDialog || !firstPluginId) return;
setDialogEntry({ pluginId: firstPluginId, view, context });
return () => setDialogEntry(null);
}, [asDialog, firstPluginId, view, context]);
const toggle = docks ? (
<PluginPanelToggle open={!folded} onClick={() => setOpen(folded)} buttonRef={toggleRef} shortcut={panelShortcutOf(shownIds)} />
) : null;
const panel = docks && !folded ? (
<>
{!overlay && (
<ResizeHandle
pane="sidePanel"
value={width}
min={APP_SIDE_PANEL_MIN_WIDTH}
max={Math.min(APP_SIDE_PANEL_MAX_WIDTH, dockedMax)}
onResizeStart={() => { widthAtStart.current = width; }}
// The panel is on the right: dragging left makes it wider.
onResize={(delta) => setWidth(clampWidth(Math.min(dockedMax, widthAtStart.current - delta)))}
onResizeEnd={() => { widthAtStart.current = width; }}
onDoubleClick={() => setWidth(Math.min(dockedMax, APP_SIDE_PANEL_DEFAULT_WIDTH))}
/>
)}
<div
ref={panelRef}
role="complementary"
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
data-app-side-panel={overlay ? 'overlay' : 'docked'}
data-app-side-panel-view={view}
className={cn(
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
)}
style={overlay
? { width: Math.min(width, Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - 48)) }
: { width, minWidth: width }}
>
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="app-side-panel" extraProps={{ view, context, focusRequest }} />
</div>
</div>
</>
) : null;
return { offered, docks, open: docks && !folded, wantsOpen: docks && !collapsed, toggle, panel, rowRef: setRowEl, close };
}
+22 -2
View File
@@ -118,6 +118,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
// Footer buttons of a custom dialog: the plugin declares them, the host
// draws them and passes each click back as `dialogButtonClick` ({ id, n }).
const [buttons, setButtons] = useState<DialogButtonSpec[]>([]);
const viewportHeight = useVisibleHeight(current?.kind === 'custom' && current.fill === true);
const [buttonClick, setButtonClick] = useState<{ id: string; n: number }>({ id: '', n: 0 });
const setDialogButtons = useCallback((value: unknown) => { setButtons(readDialogButtons(value)); }, []);
const pressButton = (id: string) => setButtonClick((c) => ({ id, n: c.n + 1 }));
@@ -220,7 +221,10 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
<div
ref={panelRef}
className="flex w-full flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
style={{ maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
style={custom && current.fill
// As tall as the visible window lets it be (a phone's keyboard takes its share).
? { maxWidth: current.width ?? 560, height: Math.min(640, Math.round(viewportHeight * 0.9) - 16) }
: { maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
data-plugin-dialog={current.pluginId}
>
{custom ? (
@@ -240,12 +244,13 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className={cn('min-h-0 flex-1 px-6 py-4', current.fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto')}>
{message}
<PluginIframeSlot
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
fill={current.fill === true}
/>
</div>
{buttons.length > 0 && (
@@ -329,6 +334,21 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
}
/** A footer button's icon by name; unknown names show none. */
/** The height of what the window shows now (the visual viewport, without a phone's keyboard), followed while `on`. */
function useVisibleHeight(on: boolean): number {
const read = () => (typeof window === 'undefined' ? 800 : Math.round(window.visualViewport?.height ?? window.innerHeight));
const [height, setHeight] = useState(read);
useEffect(() => {
if (!on || typeof window === 'undefined') return;
const update = () => setHeight(read());
update();
const target = window.visualViewport ?? window;
target.addEventListener('resize', update);
return () => target.removeEventListener('resize', update);
}, [on]);
return height;
}
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
return Glyph ? <Glyph className="w-4 h-4" aria-hidden="true" /> : null;
@@ -143,6 +143,12 @@ describe('Pro email view - editing a draft', () => {
expect(initialData.draftAccountId).toBeUndefined();
});
// The ticks in the main list are another view's: the tab's side panel doesn't get them.
it('gives the side panel of an email tab no list selection', async () => {
await openAndEdit(undefined);
expect(viewerProps.current?.withListSelection).toBe(false);
});
// Pro shell with the 0.16.23 login active and the 1.0 login's Drafts
// browsed: the store reads a row of that list through the 1.0 login. A
// view that names no login can't tell, so it names none - the active one
+1
View File
@@ -541,6 +541,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
withListSelection={false}
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
@@ -0,0 +1,126 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
import { framePropsOf } from '@/components/plugins/__tests__/fake-slot-offer';
// The plugin side panel (app-side-panel) in Settings: on desktop an h-14 row
// over the page and the panel with the page's name and the button at its
// end; below 1024 px Settings is phone-shaped and the panel is a dialog.
const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
}));
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useMessages: () => ({}),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
const media = vi.hoisted(() => ({ desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => media.desktop,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: !media.desktop, isDesktop: media.desktop }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => <div>accessibility pane</div> }));
const { SettingsApp } = await import('../settings-app');
const registry = await import('@/lib/plugin-sandbox/registry');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
function activate(id: string, name: string, slots: string[]) {
const plugin: InstalledPlugin = {
id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions: ['ui:settings-section', 'ui:side-panel'], grantedPermissions: ['ui:settings-section', 'ui:side-panel'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
registry.register({
plugin, code: '', tier: 'untrusted',
background: { evaluateShouldShow: vi.fn(async () => true) } as never,
slotOffers: slots.map((slot) => ({ name: slot as never, order: 100, hasShouldShow: false })),
hookDisposables: [],
});
}
beforeEach(() => {
media.desktop = true;
createSlotInstance.mockClear();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
// Every capability check answers "no"; ids are plain strings.
const client = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
});
afterEach(() => {
for (const p of registry.all()) registry.deregister(p.plugin.id);
vi.unstubAllGlobals();
});
const { setActionDispatcher } = await import('@/lib/plugin-sandbox/actions');
describe('Settings side panel', () => {
it('adds no row without a plugin', () => {
render(<SettingsApp linkSegments={[]} />);
expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
});
it('puts the page name and the button in an h-14 row over the page and the panel', () => {
activate('assistant', 'Assistant', ['app-side-panel']);
render(<SettingsApp linkSegments={[]} />);
const row = document.querySelector<HTMLElement>('[data-settings-panel-row]')!;
expect(row.className).toContain('h-14');
const toggle = row.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
expect(row.lastElementChild).toBe(toggle);
const frame = screen.getByTestId('plugin-frame-app-side-panel');
const page = (framePropsOf(frame).context as { page: { id: string; label: string } }).page;
expect(page.id).toBe('appearance');
expect(row.firstElementChild!.textContent).toBe(page.label);
expect(page.label).not.toBe('');
// The page itself (main) and then the panel, in the row under it.
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(panel.parentElement!.querySelector('main, [role=main]')).not.toBeNull();
fireEvent.click(screen.getByTestId('settings-tab-accessibility'));
expect((framePropsOf(screen.getByTestId('plugin-frame-app-side-panel')).context as { page: { id: string } }).page.id).toBe('accessibility');
});
it('asks for the dialog below 1024 px, from the phone header', () => {
media.desktop = false;
const dispatch = vi.fn().mockResolvedValue(undefined);
setActionDispatcher(dispatch);
activate('assistant', 'Assistant', ['app-side-panel']);
render(<SettingsApp linkSegments={[]} />);
expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
const buttons = document.querySelectorAll<HTMLElement>('[data-app-side-panel-phone]');
expect(buttons.length).toBeGreaterThan(0);
fireEvent.click(buttons[0]);
expect(dispatch).toHaveBeenCalledWith('assistant', 'app-side-panel', 'open', expect.objectContaining({ view: 'settings' }));
setActionDispatcher(null);
});
it('keeps the page (and what was typed on it) when the panel starts docking', () => {
render(<SettingsApp linkSegments={[]} />);
const pane = screen.getByText('appearance pane');
act(() => activate('assistant', 'Assistant', ['app-side-panel']));
expect(document.querySelector('[data-settings-panel-row]')).not.toBeNull();
expect(pane.isConnected).toBe(true);
});
});
+36 -7
View File
@@ -75,6 +75,7 @@ import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { PhoneHeaderActions } from '@/components/layout/phone-header-actions';
import { useAppSidePanel } from '@/components/plugins/app-side-panel';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
@@ -803,6 +804,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// "<Page> - Settings - <account> - <app>" (WCAG 2.4.2). On a phone the
// page list alone, with no page open, is just Settings.
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
// The plugin side panel (app-side-panel), with the open page. Docked on
// desktop under a row with the page's name and its button; below 1024 px
// Settings is phone-shaped and the panel opens as a dialog.
const sidePanelContext = useMemo(
() => ({ page: { id: String(effectiveActiveTab), label: activeTabLabel } }),
[effectiveActiveTab, activeTabLabel],
);
const sidePanel = useAppSidePanel({ view: 'settings', context: sidePanelContext, dock: isDesktop });
useDocumentTitle(
activeTabLabel && (isDesktop || mobileShowContent)
? `${activeTabLabel} - ${tSidebar('settings')}`
@@ -1064,6 +1074,16 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
}
// Desktop layout
const settingsMain = (
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{renderTabContent()}
</div>
</AppMain>
);
return (
<div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
@@ -1211,14 +1231,23 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{renderTabContent()}
{/* The page under a row with its name and the plugin side panel's
button, like the reader's toolbar, lined up with the sidebar's h-14
row. The same element with or without the panel, so a page keeps
what was typed when the panel starts or stops docking. */}
<div className="flex-1 min-w-0 flex flex-col" data-settings-side-panel-column="">
{sidePanel.docks && (
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-settings-panel-row="">
{/* The page's h1 below names it for screen readers. */}
<div className="flex-1 min-w-0 truncate text-sm font-semibold" aria-hidden="true">{activeTabLabel}</div>
{sidePanel.toggle}
</div>
)}
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
{settingsMain}
{sidePanel.panel}
</div>
</AppMain>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
+2 -2
View File
@@ -72,9 +72,9 @@ describe('readPhotoExif', () => {
}
});
it('falls back to the file date when the original date is missing', () => {
it('gives no date when only the date the file changed is there', () => {
const bytes = jpeg(tiff(false, { ifd0: [{ tag: 0x0132, type: 2, ascii: '2026:11:01 09:00:00' }], exif: [], gps: [] }));
expect(readPhotoExif(bytes)).toEqual({ taken: '2026-11-01T09:00:00' });
expect(readPhotoExif(bytes)).toEqual({});
});
it('gives nothing for a JPEG without EXIF, a PNG or broken bytes', () => {
+54 -18
View File
@@ -2,14 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { shrinkImage, toBase64, MAX_SHRUNK_IMAGE_BYTES } from '../image-shrink';
// jsdom has no image decoder and no canvas: the browser's createImageBitmap
// and OffscreenCanvas are stood in for. A "picture" here is a blob holding
// JSON: its size and whether it has see-through pixels. Encoded sizes follow
// a formula the test sets, so the byte cap can be hit on purpose.
// and OffscreenCanvas are stood in for. A "picture" is a PNG header (the
// size the file declares; colour type 6 for see-through pixels). Encoded
// sizes follow a formula the test sets, so the byte cap can be hit on purpose.
interface Fake { w: number; h: number; alpha?: boolean }
interface Fake { w: number; h: number; alpha?: boolean; broken?: boolean }
let bytesPer: (type: string, quality: number | undefined, w: number, h: number) => number;
const encoded: { type: string; quality?: number; w: number; h: number }[] = [];
const fills: string[] = [];
const decodes: unknown[] = [];
let oldBrowser = false;
class FakeBitmap {
constructor(readonly width: number, readonly height: number, readonly alpha: boolean) {}
@@ -40,29 +42,47 @@ class FakeCanvas {
}
}
const picture = (fake: Fake, type = 'image/jpeg') => new Blob([JSON.stringify(fake)], { type });
/** A PNG header declaring `w` × `h`; colour type 6 (alpha), 2, or 99 for a file that won't decode. */
function header({ w, h, alpha, broken }: Fake): Uint8Array {
const b = new Uint8Array(33);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
const view = new DataView(b.buffer);
view.setUint32(16, w);
view.setUint32(20, h);
b[24] = 8;
b[25] = broken ? 99 : alpha ? 6 : 2;
return b;
}
const picture = (fake: Fake, type = 'image/jpeg') => new Blob([header(fake) as BlobPart], { type });
beforeEach(() => {
encoded.length = 0;
fills.length = 0;
decodes.length = 0;
oldBrowser = false;
bytesPer = () => 1000;
vi.stubGlobal('OffscreenCanvas', FakeCanvas);
vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options: unknown) => {
expect(options).toEqual({ imageOrientation: 'from-image' });
const text = await blob.text();
if (text === 'broken') throw new DOMException('cannot decode', 'InvalidStateError');
const fake = JSON.parse(text) as Fake;
return new FakeBitmap(fake.w, fake.h, !!fake.alpha);
vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options?: { resizeWidth?: number }) => {
decodes.push(options);
if (oldBrowser && options) throw new TypeError("Failed to execute 'createImageBitmap': The provided value 'from-image' is not a valid enum value");
const b = new Uint8Array(await blob.arrayBuffer());
const view = new DataView(b.buffer);
const w = view.getUint32(16);
const h = view.getUint32(20);
if (b[25] === 99) throw new DOMException('cannot decode', 'InvalidStateError');
const width = options?.resizeWidth ?? w;
return new FakeBitmap(width, Math.round((h * width) / w), b[25] === 6);
}));
});
afterEach(() => vi.unstubAllGlobals());
describe('shrinkImage', () => {
it('shrinks the long edge to 1568 px as a JPEG at 0.85 on white', async () => {
it('shrinks the long edge to 1568 px as a JPEG at 0.85 on white, decoding at that size', async () => {
const out = await shrinkImage(picture({ w: 4000, h: 3000 }));
expect(out).toMatchObject({ status: 'ok', type: 'image/jpeg', width: 1568, height: 1176 });
expect(out.bytes?.length).toBe(1000);
expect(decodes).toEqual([{ imageOrientation: 'from-image', resizeWidth: 1568, resizeQuality: 'high' }]);
expect(encoded).toEqual([{ type: 'image/jpeg', quality: 0.85, w: 1568, h: 1176 }]);
expect(fills).toEqual(['#ffffff']);
});
@@ -70,20 +90,40 @@ describe('shrinkImage', () => {
it('takes a smaller long edge, and never makes an image larger', async () => {
expect(await shrinkImage(picture({ w: 3000, h: 4000 }), { maxSide: 1024 })).toMatchObject({ width: 768, height: 1024 });
expect(await shrinkImage(picture({ w: 800, h: 600 }))).toMatchObject({ status: 'ok', width: 800, height: 600 });
// Small enough already: decoded as it is.
expect(decodes.at(-1)).toEqual({ imageOrientation: 'from-image' });
});
it('skips tracking pixels and tiny images without drawing them', async () => {
it('skips tracking pixels and tiny images without decoding them', async () => {
expect(await shrinkImage(picture({ w: 1, h: 1 }, 'image/gif'))).toEqual({ status: 'tracker', width: 1, height: 1 });
expect(await shrinkImage(picture({ w: 2, h: 2 }))).toMatchObject({ status: 'tracker' });
expect(await shrinkImage(picture({ w: 40, h: 600 }))).toMatchObject({ status: 'tiny' });
expect(await shrinkImage(picture({ w: 100, h: 100 }))).toMatchObject({ status: 'tiny' });
expect(encoded).toEqual([]);
expect(decodes).toEqual([]);
expect(await shrinkImage(picture({ w: 130, h: 130 }))).toMatchObject({ status: 'ok' });
// Asked for on purpose: small ones too, but never a tracker.
expect(await shrinkImage(picture({ w: 40, h: 40 }), { small: true })).toMatchObject({ status: 'ok', width: 40 });
expect(await shrinkImage(picture({ w: 1, h: 2 }), { small: true })).toMatchObject({ status: 'tracker' });
});
it('refuses an image that declares more than 50 megapixels without decoding it', async () => {
expect(await shrinkImage(picture({ w: 20_000, h: 20_000 }, 'image/png'))).toEqual({ status: 'too_big', width: 20_000, height: 20_000 });
expect(decodes).toEqual([]);
expect(await shrinkImage(picture({ w: 8_000, h: 6_000 }))).toMatchObject({ status: 'ok' });
});
it('answers unsupported for a header it can not read, and for what the browser can not decode', async () => {
expect(await shrinkImage(new Blob(['not an image at all, just text'], { type: 'image/heic' }))).toEqual({ status: 'unsupported' });
expect(decodes).toEqual([]);
expect(await shrinkImage(picture({ w: 300, h: 200, broken: true }))).toMatchObject({ status: 'unsupported' });
});
it('decodes without options on a browser that does not know them', async () => {
oldBrowser = true;
expect(await shrinkImage(picture({ w: 600, h: 400 }))).toMatchObject({ status: 'ok', width: 600, height: 400 });
expect(decodes).toEqual([{ imageOrientation: 'from-image' }, undefined]);
});
it('keeps a PNG with see-through parts as PNG, and turns an opaque one into JPEG', async () => {
expect(await shrinkImage(picture({ w: 600, h: 400, alpha: true }, 'image/png'))).toMatchObject({ status: 'ok', type: 'image/png' });
expect(fills).toEqual([]);
@@ -102,10 +142,6 @@ describe('shrinkImage', () => {
bytesPer = () => MAX_SHRUNK_IMAGE_BYTES + 1;
expect(await shrinkImage(picture({ w: 2000, h: 1000 }))).toEqual({ status: 'too_big', width: 2000, height: 1000 });
});
it('answers unsupported for what the browser can not decode', async () => {
expect(await shrinkImage(new Blob(['broken'], { type: 'image/heic' }))).toEqual({ status: 'unsupported' });
});
});
describe('toBase64', () => {
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { imageDimensions } from '../image-size';
// The size an image declares, read from the header of each format.
const bytes = (...parts: (number[] | string)[]) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p)));
const be32 = (n: number) => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
const le16 = (n: number) => [n & 255, (n >>> 8) & 255];
const le24 = (n: number) => [n & 255, (n >>> 8) & 255, (n >>> 16) & 255];
const le32 = (n: number) => [...le16(n & 0xffff), ...le16(n >>> 16)];
describe('imageDimensions', () => {
it('reads PNG and GIF', () => {
expect(imageDimensions(bytes([0x89], 'PNG', [13, 10, 26, 10, 0, 0, 0, 13], 'IHDR', be32(40_000), be32(30_000), [8, 2, 0, 0, 0]))).toEqual({ width: 40_000, height: 30_000 });
expect(imageDimensions(bytes('GIF89a', le16(320), le16(200), [0, 0, 0]))).toEqual({ width: 320, height: 200 });
});
it('reads a JPEG frame header after an EXIF block', () => {
const app1 = [0xff, 0xe1, 0x00, 0x10, ...new Array(14).fill(0)];
const sof = [0xff, 0xc2, 0x00, 0x11, 8, 0x0b, 0xb8, 0x0f, 0xa0, 3, ...new Array(9).fill(0)];
expect(imageDimensions(bytes([0xff, 0xd8], app1, sof))).toEqual({ width: 4000, height: 3000 });
// No frame header before the image data: unknown.
expect(imageDimensions(bytes([0xff, 0xd8], app1, [0xff, 0xda, 0, 2, 0, 0, 0, 0, 0, 0]))).toBeNull();
});
it('reads the three kinds of WebP', () => {
const riff = (chunk: string, body: number[]) => bytes('RIFF', [0, 0, 0, 0], 'WEBP', chunk, [0, 0, 0, 0], body);
expect(imageDimensions(riff('VP8 ', [0, 0, 0, 0x9d, 0x01, 0x2a, ...le16(640), ...le16(480)]))).toEqual({ width: 640, height: 480 });
// VP8L: 14 bits each of width - 1 and height - 1 after the 0x2f signature.
const w = 1023;
const h = 767;
const packed = (w - 1) | ((h - 1) << 14);
expect(imageDimensions(riff('VP8L', [0x2f, ...le32(packed), 0]))).toEqual({ width: w, height: h });
expect(imageDimensions(riff('VP8X', [0, 0, 0, 0, ...le24(16_383), ...le24(9_999)]))).toEqual({ width: 16_384, height: 10_000 });
});
it('reads BMP, and nothing from what it does not know', () => {
expect(imageDimensions(bytes('BM', new Array(16).fill(0), le32(800), le32(-600 >>> 0)))).toEqual({ width: 800, height: 600 });
expect(imageDimensions(bytes('<svg width="100000" height="100000">'))).toBeNull();
expect(imageDimensions(new Uint8Array(0))).toBeNull();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
// getThreadEmails with `newest`: Thread/get first, then Email/get only for
// the newest messages, so a thread of thousands never loads all its bodies.
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, { status, headers: { 'Content-Type': 'application/json' } });
}
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
describe('JMAPClient.getThreadEmails newest', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
afterEach(() => { fetchSpy.mockRestore(); });
async function threadOf(ids: string[], options?: { newest?: number }): Promise<string[][]> {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
const asked: string[][] = [];
fetchSpy.mockImplementation(async (_input: unknown, init?: RequestInit) => {
const [name, args] = JSON.parse((init as RequestInit).body as string).methodCalls[0];
if (name === 'Thread/get') return mockFetchResponse(200, { methodResponses: [['Thread/get', { list: [{ id: 't', emailIds: ids }] }, '0']] });
asked.push(args.ids);
return mockFetchResponse(200, { methodResponses: [['Email/get', { list: [] }, '0']] });
});
await client.getThreadEmails('t', undefined, options);
return asked;
}
it('fetches only the newest messages of the thread', async () => {
const ids = Array.from({ length: 80 }, (_, i) => `e${i}`);
expect((await threadOf(ids, { newest: 50 })).flat()).toEqual(ids.slice(30));
});
it('fetches every message without it, as before', async () => {
expect((await threadOf(['a', 'b', 'c'])).flat()).toEqual(['a', 'b', 'c']);
});
});
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { slotPropsFor, mayOfferSlot } from '@/lib/plugin-sandbox/permissions';
import { PANEL_SLOTS, onPanelRequest, requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types';
import { slotSpec } from '@/lib/plugin-slot-catalog';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types';
// The app-side-panel slot (area f of the assistant's host APIs): a side
// panel in Calendar, Contacts, Files and Settings. Its context carries the
// open event, contact, files or settings page, each behind the permission
// that guards that data elsewhere.
const plugin = (permissions: Permission[]): InstalledPlugin => ({
id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions, grantedPermissions: permissions, entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
});
const context = {
event: { id: 'e1', title: 'Team offsite' },
contact: { id: 'c1', fullName: 'Aa Bob' },
selectedCount: 3,
files: [{ id: 'f1', name: 'agenda.pdf' }],
fileCount: 1,
folder: { id: 'd1', path: '/Projects' },
page: { id: 'reading', label: 'Reading' },
secret: 'not in the catalogue',
};
afterEach(() => resetPanelRequests());
describe('app-side-panel', () => {
it('is a slot of its own permission, fillable, and a panel plugins can open', () => {
expect(ALL_PERMISSIONS).toContain('ui:side-panel');
expect(slotSpec('app-side-panel')?.permissions).toEqual(['ui:side-panel']);
expect(mayOfferSlot(plugin(['ui:side-panel']), 'app-side-panel')).toBe(true);
expect(mayOfferSlot(plugin(['ui:email-details']), 'app-side-panel')).toBe(false);
expect(isFillableSlot('app-side-panel')).toBe(true);
expect(PANEL_SLOTS).toContain('app-side-panel');
expect(assistantFeatures().sidePanel).toBe(true);
});
it('gives the plugin only the parts of the context it has permissions for', () => {
const props = { view: 'calendar', context, focusRequest: 2 };
// ui:side-panel alone: the view and the settings page, nothing of the user's data.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', props)).toEqual({
view: 'calendar', context: { page: context.page }, focusRequest: 2,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', props).context).toEqual({
event: context.event, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'contacts:read']), 'app-side-panel', props).context).toEqual({
contact: context.contact, selectedCount: 3, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'files:read']), 'app-side-panel', props).context).toEqual({
files: context.files, fileCount: 1, folder: context.folder, page: context.page,
});
// A part the catalogue doesn't name never goes out, whatever the plugin may do.
const all = slotPropsFor(plugin(['ui:side-panel', 'calendar:read', 'contacts:read', 'files:read']), 'app-side-panel', props);
expect(all.context).not.toHaveProperty('secret');
expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort());
});
it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => {
// Importing the loader installs its action dispatcher, as in the app.
await import('@/lib/plugin-sandbox/loader');
const invokeHook = vi.fn(async () => undefined);
register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin);
try {
await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } });
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]);
} finally {
deregister('p');
}
// Garbage in the context is dropped, not passed on.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({});
// The loader's import is slow under load.
}, 30_000);
it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => {
expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true);
// Enabled before an update added the permission: not granted yet.
const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] };
expect(assistantFeatures(notGranted).sidePanel).toBe(false);
expect(assistantFeatures(plugin([])).sidePanel).toBe(false);
});
it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => {
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBeNull();
const handler = vi.fn((request: { slot: string }) => (request.slot === 'app-side-panel' ? true : null));
onPanelRequest(handler);
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBe(true);
expect(handler).toHaveBeenCalledWith({ pluginId: 'p', slot: 'app-side-panel', mode: 'open' });
expect(requestPanel('p', 'email-detail-sidebar', 'open', undefined)).toBeNull();
});
});
@@ -17,7 +17,7 @@ import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
// jsdom can't decode or draw images: createImageBitmap and OffscreenCanvas
// are stood in for. A "picture" is a blob holding its size as JSON.
// are stood in for. A "picture" is a PNG header declaring its size.
function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
return {
@@ -42,6 +42,16 @@ const SIZES: Record<string, { w: number; h: number }> = {
'blob-photo': { w: 3000, h: 2000 }, 'blob-logo': { w: 240, h: 160 }, 'blob-pixel': { w: 1, h: 1 },
};
function pngHeader({ w, h }: { w: number; h: number }): Uint8Array {
const b = new Uint8Array(33);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
new DataView(b.buffer).setUint32(16, w);
new DataView(b.buffer).setUint32(20, h);
b[24] = 8;
b[25] = 2;
return b;
}
const message = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, subject: 'Logo', receivedAt: '2026-05-01T10:00:00Z',
htmlBody: [{ partId: '1', blobId: 'b-html', size: 1, type: 'text/html' }],
@@ -63,13 +73,16 @@ beforeEach(() => {
resetPluginRateLimits();
exif.read.mockClear();
vi.stubGlobal('OffscreenCanvas', FakeCanvas);
vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob) => {
const { w, h } = JSON.parse(await blob.text()) as { w: number; h: number };
return { width: w, height: h, close() {} };
vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options?: { resizeWidth?: number }) => {
const view = new DataView(await blob.arrayBuffer());
const w = view.getUint32(16);
const h = view.getUint32(20);
const width = options?.resizeWidth ?? w;
return { width, height: Math.round((h * width) / w), close() {} };
}));
client = {
getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)),
fetchBlobArrayBuffer: vi.fn(async (blobId: string) => new TextEncoder().encode(JSON.stringify(SIZES[blobId] ?? { w: 0, h: 0 })).buffer),
fetchBlobArrayBuffer: vi.fn(async (blobId: string) => pngHeader(SIZES[blobId] ?? { w: 0, h: 0 }).buffer),
getAccountId: vi.fn(() => 'acc1'),
getMailboxes: vi.fn(async () => []),
};
+11 -6
View File
@@ -19,8 +19,8 @@ import {
} from '@/lib/plugin-sandbox/host-features';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top; their own tests check them. */
const withAssistant = (core: Record<string, boolean>) => ({ ...core, ...assistantFeatures() });
/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top, as `plugin` sees them; their own tests check them. */
const withAssistant = (core: Record<string, boolean>, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
@@ -85,21 +85,21 @@ describe('host bridge', () => {
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// ... after it, as a host-change message.
const sent: unknown[] = [];
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
setExternalMail({ recipientWarning: true });
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }) }]);
expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }, plugin) }]);
// The admin turns third-party traffic off: running plugins hear it.
setNoThirdPartyTraffic(true);
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }) });
expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }, plugin) });
setNoThirdPartyTraffic(false);
instance.destroy();
}, 30_000);
@@ -172,5 +172,10 @@ describe('api.host in the sandbox runtime', () => {
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: 'yes' } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
// A flag the host no longer names turns off instead of keeping its old value.
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: true } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
}, 30_000);
});
+49 -2
View File
@@ -50,6 +50,15 @@ beforeEach(() => {
if (threadId === 'big') return Array.from({ length: 60 }, (_, i) => mail(`b${i}`, i, `Part ${i}`, 'big'));
return [];
}),
getSomeEmails: vi.fn(async (ids: string[], accountId?: string) => {
const all = accountId === 'shared-acc' ? [mail('s1', 1, 'Team news', 'ts')] : [...THREAD, mail('lonely', 5, 'Alone', '')];
return all.filter((e) => ids.includes(e.id));
}),
getThread: vi.fn(async (threadId: string, accountId?: string) => {
if (threadId === 't1' && !accountId) return { id: 't1', emailIds: ['m1', 'm2', 'm3'] };
if (threadId === 'ts' && accountId === 'shared-acc') return { id: 'ts', emailIds: ['s1', 's2'] };
return null;
}),
getAccountId: vi.fn(() => 'acc1'),
getMailboxes: vi.fn(async () => [{ id: 'shared-inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as unknown as Mailbox]),
};
@@ -80,13 +89,14 @@ describe('api.email.getThread', () => {
['m2', 'Re: Report', 'Re: Report body', 'acc1'],
['m3', 'Re: Re: Report', 'Re: Re: Report body', 'acc1'],
]);
expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined);
// Only the newest 50 are fetched from the server.
expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined, { newest: 50 });
});
it('reads a shared account\'s conversation from that account', async () => {
const thread = await call(ASSISTANT, 's1', { accountId: 'shared-acc' });
expect(thread.map((e) => [e.id, e.accountId])).toEqual([['s1', 'shared-acc'], ['s2', 'shared-acc']]);
expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc');
expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc', { newest: 50 });
});
it('refuses an account this login does not show, and a missing message', async () => {
@@ -112,3 +122,40 @@ describe('api.email.getThread', () => {
await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
});
});
describe('api.email.getThreadCount', () => {
const count = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getThreadCount', args) as Promise<number>;
it('is behind email:read and offered in the runtime', async () => {
expect(PERM_PER_METHOD['email.getThreadCount']).toBe('email:read');
await expect(count(plugin(['email:blob-read']), 'm2')).rejects.toThrow(/lacks permission "email:read"/);
const calls: unknown[][] = [];
const api = withAssistantRuntime({} as { email?: { getThreadCount?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return 0; });
await api.email?.getThreadCount?.('m2');
expect(calls).toEqual([['email.getThreadCount', ['m2', undefined]]]);
});
it('counts the messages of the conversation without reading any of them', async () => {
expect(await count(ASSISTANT, 'm2')).toBe(3);
expect(client.getThread).toHaveBeenCalledWith('t1', undefined);
expect(client.getEmail).not.toHaveBeenCalled();
expect(client.getThreadEmails).not.toHaveBeenCalled();
// A message without a thread is a conversation of one.
expect(await count(ASSISTANT, 'lonely')).toBe(1);
});
it('counts in a shared account there, and refuses accounts this login does not show', async () => {
expect(await count(ASSISTANT, 's1', { accountId: 'shared-acc' })).toBe(2);
expect(client.getThread).toHaveBeenCalledWith('ts', 'shared-acc');
await expect(count(ASSISTANT, 'm2', { accountId: 'stranger' })).rejects.toThrow(/no message with id "m2"/);
expect(client.getSomeEmails).not.toHaveBeenCalledWith(['m2'], 'stranger');
await expect(count(ASSISTANT, 'nope')).rejects.toThrow(/no message with id "nope"/);
});
it('has its own limit of 120 a minute, apart from getThread\'s', async () => {
for (let i = 0; i < 60; i++) await call(ASSISTANT, 'm1');
await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
for (let i = 0; i < 120; i++) await count(ASSISTANT, 'm1');
await expect(count(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
});
});
+2 -3
View File
@@ -12,7 +12,6 @@ export interface PhotoExif {
const TAG_EXIF_IFD = 0x8769;
const TAG_GPS_IFD = 0x8825;
const TAG_DATETIME = 0x0132;
const TAG_DATETIME_ORIGINAL = 0x9003;
const TAG_OFFSET_TIME_ORIGINAL = 0x9011;
const GPS_LAT_REF = 1;
@@ -126,8 +125,8 @@ export function readPhotoExif(data: ArrayBuffer | Uint8Array): PhotoExif {
const ifd0 = tiff.ifd(tiff.firstIfd());
const exifAt = tiff.long(ifd0.get(TAG_EXIF_IFD));
const exif = exifAt ? tiff.ifd(exifAt) : new Map<number, Entry>();
const taken = exifDate(tiff.ascii(exif.get(TAG_DATETIME_ORIGINAL)), tiff.ascii(exif.get(TAG_OFFSET_TIME_ORIGINAL)))
?? exifDate(tiff.ascii(ifd0.get(TAG_DATETIME)), undefined);
// Only the date the photo was taken: IFD0's DateTime is when the file was last changed.
const taken = exifDate(tiff.ascii(exif.get(TAG_DATETIME_ORIGINAL)), tiff.ascii(exif.get(TAG_OFFSET_TIME_ORIGINAL)));
if (taken) out.taken = taken;
const gpsAt = tiff.long(ifd0.get(TAG_GPS_IFD));
if (gpsAt) {
+39 -8
View File
@@ -5,6 +5,8 @@
// has see-through parts. Images too small to show anything (tracking pixels,
// spacers, icons) are not drawn at all.
import { IMAGE_HEADER_BYTES, imageDimensions } from './image-size';
/** Default and allowed range of the long edge, in pixels. */
export const DEFAULT_IMAGE_SIDE = 1568;
export const MIN_IMAGE_SIDE = 64;
@@ -61,27 +63,56 @@ export function fitSize(width: number, height: number, maxSide: number): { width
/** Types the browser may decode that can carry transparency. */
const ALPHA_TYPES = /^image\/(png|webp|gif|avif)$/i;
/** Most pixels an image may declare; a larger one is not decoded (its pixels alone could fill the memory). */
export const MAX_IMAGE_PIXELS = 50_000_000;
/**
* Decodes with the photo's EXIF orientation and, when given, a smaller
* width. Browsers before imageOrientation (or resize) throw a TypeError on
* the options: then without them.
*/
async function decode(blob: Blob, resizeWidth: number | null): Promise<ImageBitmap> {
try {
return await createImageBitmap(blob, {
imageOrientation: 'from-image',
// Only the width: the browser keeps the aspect, whichever way EXIF turns the photo.
...(resizeWidth ? { resizeWidth, resizeQuality: 'high' as const } : {}),
});
} catch (err) {
if (err instanceof TypeError) return createImageBitmap(blob);
throw err;
}
}
/**
* The image in `blob`, at most `maxSide` on its long edge and
* MAX_SHRUNK_IMAGE_BYTES in size. JPEG at quality 0.85, then 0.7, then
* smaller by a quarter per step until it fits; PNG when transparent.
* `small`: also shrink images below the "tiny" limits (still not trackers).
* The size comes from the file's header before anything is decoded: a
* format whose header isn't read here is unsupported, more than
* MAX_IMAGE_PIXELS too_big, trackers and tiny images are never decoded.
*/
export async function shrinkImage(blob: Blob, { maxSide = DEFAULT_IMAGE_SIDE, small = false }: { maxSide?: number; small?: boolean } = {}): Promise<ShrinkResult> {
if (typeof createImageBitmap !== 'function') return { status: 'unsupported' };
const declared = imageDimensions(new Uint8Array(await blob.slice(0, IMAGE_HEADER_BYTES).arrayBuffer()));
// HEIC, TIFF, SVG, a damaged file: no size to check, nothing to decode.
if (!declared || declared.width < 1 || declared.height < 1) return { status: 'unsupported' };
const { width: w, height: h } = declared;
if (w * h > MAX_IMAGE_PIXELS) return { status: 'too_big', width: w, height: h };
if (w <= TRACKER_SIDE && h <= TRACKER_SIDE) return { status: 'tracker', width: w, height: h };
if (!small && (Math.min(w, h) < TINY_SIDE || w * h < TINY_AREA)) return { status: 'tiny', width: w, height: h };
const side = Math.max(MIN_IMAGE_SIDE, Math.min(MAX_IMAGE_SIDE, Math.round(maxSide)));
const target = fitSize(w, h, side);
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' });
bitmap = await decode(blob, target.width < w ? target.width : null);
} catch {
// HEIC, TIFF, a broken file: nothing the browser can show.
return { status: 'unsupported' };
// A broken file: nothing the browser can show.
return { status: 'unsupported', width: w, height: h };
}
try {
const { width: w, height: h } = bitmap;
if (w <= TRACKER_SIDE && h <= TRACKER_SIDE) return { status: 'tracker', width: w, height: h };
if (!small && (Math.min(w, h) < TINY_SIDE || w * h < TINY_AREA)) return { status: 'tiny', width: w, height: h };
const side = Math.max(MIN_IMAGE_SIDE, Math.min(MAX_IMAGE_SIDE, Math.round(maxSide)));
let size = fitSize(w, h, side);
let size = fitSize(bitmap.width, bitmap.height, side);
let transparent: boolean | null = ALPHA_TYPES.test(blob.type) ? null : false;
for (let step = 0; step < 12; step++) {
const canvas = makeCanvas(size.width, size.height);
+66
View File
@@ -0,0 +1,66 @@
// The width and height an image file declares in its header, read before
// anything decodes it: PNG (IHDR), GIF, JPEG (the SOF segment), WebP
// (VP8, VP8L, VP8X) and BMP. A file can declare a size whose pixels alone
// would fill the memory, so callers check this first.
export interface ImageDimensions {
width: number;
height: number;
}
/** Bytes of a file's start that hold the size of every format read here, EXIF blocks before a JPEG's SOF included. */
export const IMAGE_HEADER_BYTES = 512 * 1024;
const u16be = (b: Uint8Array, at: number) => (b[at] << 8) | b[at + 1];
const u16le = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8);
const u24le = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
const u32be = (b: Uint8Array, at: number) => ((b[at] << 24) >>> 0) + ((b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]);
const ascii = (b: Uint8Array, at: number, text: string) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
function jpeg(b: Uint8Array): ImageDimensions | null {
let at = 2;
while (at + 9 < b.length) {
if (b[at] !== 0xff) return null;
const marker = b[at + 1];
// Fill bytes, and markers without a length (restart markers, TEM).
if (marker === 0xff) { at++; continue; }
if ((marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { at += 2; continue; }
if (marker === 0xd9 || marker === 0xda) return null;
const length = u16be(b, at + 2);
// Start of frame: every SOF but DHT (C4), JPG (C8) and DAC (CC).
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: u16be(b, at + 5), width: u16be(b, at + 7) };
}
if (length < 2) return null;
at += 2 + length;
}
return null;
}
function webp(b: Uint8Array): ImageDimensions | null {
if (b.length >= 30 && ascii(b, 12, 'VP8 ') && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff };
}
if (ascii(b, 12, 'VP8L') && b[20] === 0x2f) {
return {
width: 1 + (((b[22] & 0x3f) << 8) | b[21]),
height: 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)),
};
}
if (b.length >= 30 && ascii(b, 12, 'VP8X')) return { width: 1 + u24le(b, 24), height: 1 + u24le(b, 27) };
return null;
}
/** The size the file declares, or null for a format not read here (or a damaged header). */
export function imageDimensions(bytes: Uint8Array): ImageDimensions | null {
const b = bytes;
if (b.length >= 24 && b[0] === 0x89 && ascii(b, 1, 'PNG') && ascii(b, 12, 'IHDR')) return { width: u32be(b, 16), height: u32be(b, 20) };
if (b.length >= 10 && ascii(b, 0, 'GIF8')) return { width: u16le(b, 6), height: u16le(b, 8) };
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) return jpeg(b);
if (b.length >= 25 && ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return webp(b);
if (b.length >= 26 && ascii(b, 0, 'BM')) {
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
return { width: Math.abs(view.getInt32(18, true)), height: Math.abs(view.getInt32(22, true)) };
}
return null;
}
+2 -1
View File
@@ -404,7 +404,8 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
/** `newest`: only that many of the thread's newest messages are fetched (Thread/get lists them oldest first). */
getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
createDraft(
+4 -2
View File
@@ -4609,17 +4609,19 @@ export class JMAPClient implements IJMAPClient {
}
}
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
async getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
const thread = await this.getThread(threadId, accountId);
if (!thread?.emailIds?.length) {
return [];
}
// Thread/get lists a thread's messages oldest first (RFC 8621 3.1).
const ids = options?.newest !== undefined && options.newest > 0 ? thread.emailIds.slice(-options.newest) : thread.emailIds;
const emails: Email[] = [];
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
for (const batchIds of batched(ids, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
@@ -2,7 +2,7 @@
// the reader panel's `listSelection` prop, so the assistant can work on
// several at once ("summarise these").
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { resolveEmailBlobContext, resolveSelectedEmails, useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -33,18 +33,15 @@ export function listSelectionOf(state: SelectionState, client: IJMAPClient | nul
return { emails, total };
}
/** listSelectionOf for the list on screen; the same object while the selection stays the same. */
export function useListSelection(): ListSelectionView | null {
const selectedEmailKeys = useEmailStore((s) => s.selectedEmailKeys);
const emails = useEmailStore((s) => s.emails);
const threadEmailsCache = useEmailStore((s) => s.threadEmailsCache);
/**
* listSelectionOf for the list on screen; the same object while the
* selection stays the same. The store is read through one selector that
* answers a string, so new mail, a search or a moved message re-render the
* caller only when the ticked messages change. `enabled` false (a Pro tab,
* whose panel isn't about the main list) always answers null.
*/
export function useListSelection(enabled = true): ListSelectionView | null {
const client = useAuthStore((s) => s.client);
const last = useRef<{ key: string; value: ListSelectionView | null }>({ key: 'null', value: null });
return useMemo(() => {
const value = listSelectionOf({ selectedEmailKeys, emails, threadEmailsCache }, client);
const key = JSON.stringify(value);
// New mail arriving re-renders the list; the panel only hears of a new selection.
if (key !== last.current.key) last.current = { key, value };
return last.current.value;
}, [selectedEmailKeys, emails, threadEmailsCache, client]);
const key = useEmailStore((s) => (enabled ? JSON.stringify(listSelectionOf(s, client)) : 'null'));
return useMemo(() => JSON.parse(key) as ListSelectionView | null, [key]);
}
+6 -1
View File
@@ -4,7 +4,7 @@
// name a picture without looking at it.
import type { Email } from '@/lib/jmap/types';
import type { AttachmentExtrasB } from './b.types';
import type { AttachmentExtrasB, EmailReadViewExtrasB } from './b.types';
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
@@ -50,6 +50,11 @@ export function cidAltTexts(email: Pick<Email, 'htmlBody' | 'bodyValues'>): Map<
return out;
}
/** EmailReadView.blobsReadable: false when the signed-in login can't read the message's parts, else left out. */
export function readability(readable: boolean): EmailReadViewExtrasB {
return readable ? {} : { blobsReadable: false };
}
/** `cid` and `alt` of one attachment, for EmailReadView.attachments (empty for a plain attachment). */
export function attachmentExtras(part: { cid?: string | null }, alts: Map<string, string>): AttachmentExtrasB {
if (!part.cid) return {};
@@ -30,6 +30,12 @@ export function runtime(callApi: CallApi): RuntimeAdditions {
*/
getThread: (emailId: string, options?: { accountId?: string }) =>
callApi('email.getThread', [emailId, options], NETWORK_API_TIMEOUT_MS) as Promise<EmailReadView[]>,
/**
* How many messages the conversation of a message has, without
* reading them (email:read, 120 a minute, apart from getThread's).
*/
getThreadCount: (emailId: string, options?: { accountId?: string }) =>
callApi('email.getThreadCount', [emailId, options]) as Promise<number>,
},
};
}
+28 -2
View File
@@ -8,7 +8,7 @@ import type { Attachment, Email } from '../../jmap/types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesB, AttachmentImageView, AttachmentTextView } from './b.types';
import { attachmentExtras, cidAltTexts } from './b.projection';
import { readNamedEmail } from '../host-api-mail';
import { readNamedEmail, sourceAccount } from '../host-api-mail';
import { readBoolean, readId, readInteger, readObject, sessionClient, withinRate } from '../host-api-input';
// The readers (pdf.js, the Office reader) load only when an attachment is read.
import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types';
@@ -21,6 +21,7 @@ export const PERMS: Readonly<Record<string, Permission | null>> = {
'email.getAttachmentText': 'email:blob-read',
'email.getAttachmentImage': 'email:blob-read',
'email.getThread': 'email:read',
'email.getThreadCount': 'email:read',
};
/** Attachment reads (text) a plugin may make per minute. */
@@ -29,6 +30,8 @@ export const MAX_ATTACHMENT_TEXT_PER_MINUTE = 30;
export const MAX_ATTACHMENT_IMAGE_PER_MINUTE = 30;
/** Conversations a plugin may read per minute. */
export const MAX_THREADS_PER_MINUTE = 60;
/** Conversation sizes a plugin may ask for per minute (a panel probing whether to offer a thread read). */
export const MAX_THREAD_COUNTS_PER_MINUTE = 120;
/** Most messages api.email.getThread returns (the newest of a longer conversation). */
export const MAX_THREAD_MESSAGES = 50;
@@ -156,7 +159,8 @@ export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unkn
const found = await readNamedEmail(client, emailId, method, opts.accountId);
if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
const accountId = found.accountId ?? client.getAccountId();
const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId) : [];
// Only the newest messages are fetched, with their bodies; a thread of thousands stays one small read.
const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId, { newest: MAX_THREAD_MESSAGES }) : [];
// A server that knows no thread for it: the message on its own.
const list = emails.length ? emails : [found.email];
return list
@@ -166,12 +170,34 @@ export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unkn
.map((email) => emailToReadView(email, accountId));
}
/**
* How many messages the conversation of `emailId` has (Thread/get; no
* message bodies are read). 1 for a message without a thread. Routed like
* api.email.get; its own rate limit, so probing it never uses up getThread's.
*/
export async function doEmailGetThreadCount(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise<number> {
const method = 'email.getThreadCount';
const emailId = readId(emailIdArg, method, 'emailId');
const opts = readObject(optionsArg, method, 'options', ['accountId'], true);
if (!withinRate(method, plugin.id, MAX_THREAD_COUNTS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
const client = sessionClient(method);
const accountId = await sourceAccount(client, emailId, method, opts.accountId);
if (accountId === null) throw new Error(`${method}: no message with id "${emailId}"`);
// The summary only (no bodies), for its thread id.
const [email] = await client.getSomeEmails([emailId], accountId);
if (!email) throw new Error(`${method}: no message with id "${emailId}"`);
if (!email.threadId) return 1;
const thread = await client.getThread(email.threadId, accountId);
return Math.max(1, thread?.emailIds?.length ?? 1);
}
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
switch (method) {
case 'email.getAttachmentText': return doEmailGetAttachmentText(plugin, args[0], args[1], args[2]);
case 'email.getAttachmentImage': return doEmailGetAttachmentImage(plugin, args[0], args[1], args[2]);
case 'email.getThread': return doEmailGetThread(plugin, args[0], args[1]);
case 'email.getThreadCount': return doEmailGetThreadCount(plugin, args[0], args[1]);
default: return NOT_MINE;
}
}
+12 -1
View File
@@ -10,12 +10,23 @@ export interface AreaHostFeaturesB {
attachmentText?: boolean;
/** api.email.getAttachmentImage: an image attachment, shrunk for a model. */
attachmentImages?: boolean;
/** api.email.getThread: every message of a conversation. */
/** api.email.getThread: every message of a conversation; api.email.getThreadCount: how many there are. */
threads?: boolean;
/** The reader panel (email-detail-sidebar) gets the messages ticked in the list as `listSelection`. */
listSelection?: boolean;
}
/** What EmailReadView adds for the assistant. */
export interface EmailReadViewExtrasB {
/**
* false: this login can't read the message's attachments, text or
* conversation through the host (it belongs to another connected login,
* as in a unified view), so attachment, image and thread reads of it
* fail; offer none. Left out when they can be read.
*/
blobsReadable?: boolean;
}
/** What EmailReadView.attachments adds for an image the message shows inline. */
export interface AttachmentExtrasB {
/** The Content-ID the message's HTML refers to (`<img src="cid:…">`), without angle brackets. */
+14 -3
View File
@@ -1,6 +1,11 @@
// Area f of the assistant's host APIs, host side (see README.md).
//
// Area f adds no methods: the app-side-panel slot (plugin-slot-catalog.ts,
// gated by ui:side-panel) and its panel requests (panel-requests.ts) are
// the whole host side, mounted by components/plugins/app-side-panel.tsx.
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { mayOfferSlot } from '../permissions';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesF } from './f.types';
@@ -12,7 +17,13 @@ export async function dispatch(_plugin: InstalledPlugin, _method: string, _args:
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesF {
return {};
/**
* This area's flags in api.host.features, right now. For one plugin,
* sidePanel says whether it may show the panel (ui:side-panel granted; a
* plugin enabled before an update that added it lacks it until turned off
* and on), so it can tell the user instead of offering a switch that does
* nothing.
*/
export function features(plugin?: InstalledPlugin): AreaHostFeaturesF {
return { sidePanel: plugin ? mayOfferSlot(plugin, 'app-side-panel') : true };
}
+63 -3
View File
@@ -1,8 +1,68 @@
// Area f of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
// api.host.features (see README.md). Pure: no imports from the app (the
// view types below are type-only).
import type { CalendarEventView, ContactView, FileResourceView } from '../../plugin-types';
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
export const PERMISSIONS = ['ui:side-panel'] as const;
/** Flags this area adds to api.host.features. */
export interface AreaHostFeaturesF {}
export interface AreaHostFeaturesF {
/**
* The app-side-panel slot: a side panel in Calendar, Contacts, Files and
* Settings, which api.ui.openPanel / closePanel / togglePanel answer for.
* Missing (false) on webmails before it.
*/
sidePanel?: boolean;
}
/** The views that show the app-side-panel. Mail has the reader's panel (email-detail-sidebar). */
export const APP_SIDE_PANEL_VIEWS = ['calendar', 'contacts', 'files', 'settings'] as const;
export type AppSidePanelViewName = typeof APP_SIDE_PANEL_VIEWS[number];
/** The most selected files the panel names; more are only counted. */
export const MAX_SIDE_PANEL_FILES = 20;
/**
* What the view shows, in the app-side-panel's `context` prop. Each field
* reaches a plugin only with its permission (APP_SIDE_PANEL_CONTEXT_FIELDS):
* the event with calendar:read, the contact and the selection count with
* contacts:read, files and folder with files:read; the settings page with
* none.
*/
export interface AppSidePanelContext {
/** Calendar: the event open in the details or the editor. */
event?: CalendarEventView;
/** Contacts: the contact open in the details. */
contact?: ContactView;
/** Contacts: how many contacts are ticked in the list. */
selectedCount?: number;
/** Files: the selected files (at most 20). */
files?: FileResourceView[];
/** Files: how many files are selected, also past the 20 in `files`. */
fileCount?: number;
/** Files: the open folder. */
folder?: { id: string | null; path: string };
/** Settings: the open page, by its id and its name in the user's language. */
page?: { id: string; label: string };
}
/** The app-side-panel's props. */
export interface AppSidePanelProps {
view: AppSidePanelViewName;
context: AppSidePanelContext;
/** Counts up when the panel opens on request (the toggle, Alt+A, api.ui.openPanel). */
focusRequest: number;
}
/** Which permission each field of the app-side-panel's `context` needs (null: none). */
export const APP_SIDE_PANEL_CONTEXT_FIELDS = {
event: 'calendar:read',
contact: 'contacts:read',
selectedCount: 'contacts:read',
files: 'files:read',
fileCount: 'files:read',
folder: 'files:read',
page: null,
} as const satisfies Record<keyof AppSidePanelContext, string | null>;
+6 -3
View File
@@ -23,7 +23,10 @@ export async function dispatchAssistant(plugin: InstalledPlugin, method: string,
return NOT_MINE;
}
/** Every area's api.host.features flags, right now. */
export function assistantFeatures(): AssistantHostFeatures {
return Object.assign({}, ...AREAS.map((a) => a.features()));
/**
* Every area's api.host.features flags, right now; with `plugin`, as that
* plugin's frames see them (a flag may depend on what it was granted).
*/
export function assistantFeatures(plugin?: InstalledPlugin): AssistantHostFeatures {
return Object.assign({}, ...AREAS.map((a) => (a.features as (p?: InstalledPlugin) => object)(plugin)));
}
+1 -1
View File
@@ -40,7 +40,7 @@ async function loadEmail(client: IJMAPClient, emailId: string, accountId?: strin
* The account to read `emailId` from: undefined for the signed-in account,
* null when it is not this login's to read.
*/
async function sourceAccount(client: IJMAPClient, emailId: string, method: string, accountArg: unknown): Promise<string | undefined | null> {
export async function sourceAccount(client: IJMAPClient, emailId: string, method: string, accountArg: unknown): Promise<string | undefined | null> {
const { emails } = useEmailStore.getState();
if (accountArg === undefined) {
// A listed message of a shared account is asked of the account that owns
+2 -1
View File
@@ -2198,7 +2198,7 @@ export async function dispatchApiCall(
// real app-root overlay, instead of a fixed confirm/prompt form - see
// the 'plugin-dialog' SlotName / host-dialog.ts comments for why this
// exists (a small toolbar/row slot can't show a large custom UI).
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number };
const opts = (args[0] ?? {}) as { title?: string; slot?: string; extraProps?: Record<string, unknown>; width?: number; fill?: boolean };
if (!opts.slot || typeof opts.slot !== 'string') {
throw new Error('ui.openDialog requires a "slot" name');
}
@@ -2209,6 +2209,7 @@ export async function dispatchApiCall(
slot: opts.slot,
extraProps: (opts.extraProps && typeof opts.extraProps === 'object') ? opts.extraProps : {},
width: typeof opts.width === 'number' ? opts.width : undefined,
...(opts.fill === true ? { fill: true } : {}),
});
}
case 'ui.rerenderEmail': {
+10 -3
View File
@@ -14,6 +14,7 @@ export type { RegionalSnapshot };
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import { snapshotHostFeatures, watchHostFeatures } from './host-features';
import { assistantFeatures } from './assistant';
import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import { resolveDateLocale } from '../utils';
@@ -257,7 +258,8 @@ function pushThemeToFrames(): void {
/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */
function pushHostFeaturesToFrames(features: HostFeaturesView): void {
for (const instance of liveInstances) {
try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ }
// Some of the assistant's flags depend on the plugin's grants (host-features.ts).
try { instance.setHostFeatures({ ...features, ...instance.ownHostFeatures() }); } catch { /* one broken iframe must not stop the rest */ }
}
}
@@ -672,6 +674,11 @@ export class SandboxInstance {
}
/** Push what api.host.features says now; before init it goes into the init payload. */
/** The flags of api.host.features that depend on this plugin. */
ownHostFeatures(): Partial<HostFeaturesView> {
return assistantFeatures(this.plugin);
}
setHostFeatures(features: HostFeaturesView): void {
if (this.destroyed) return;
if (this.pendingInit) {
@@ -782,7 +789,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
code: opts.code,
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
hostFeatures: snapshotHostFeatures(),
hostFeatures: snapshotHostFeatures(opts.plugin),
};
return new SandboxInstance(
opts.plugin,
@@ -812,7 +819,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
theme: snapshotHostTheme(),
hostFeatures: snapshotHostFeatures(),
hostFeatures: snapshotHostFeatures(opts.plugin),
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
}
+6
View File
@@ -26,6 +26,12 @@ export type DialogResult = boolean | Record<string, string> | null;
export interface DialogRequest {
id: string;
/**
* For `kind === 'custom'`: the plugin's frame fills the dialog, which is as
* tall as the window lets it be (at most 640 px), instead of growing with
* its content. For chats, whose input stays at the bottom.
*/
fill?: boolean;
pluginId: string;
kind: DialogKind;
title: string;
+3 -3
View File
@@ -11,7 +11,7 @@
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
import type { HostFeaturesView } from '../plugin-types';
import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { assistantFeatures } from './assistant';
@@ -27,11 +27,11 @@ export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail']
};
}
export function snapshotHostFeatures(): HostFeaturesView {
export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView {
return {
...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()),
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
...assistantFeatures(),
...assistantFeatures(plugin),
};
}
+7 -4
View File
@@ -2,14 +2,17 @@
// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels
// and the reader's detail panel. A toolbar button, a shortcut or a
// right-click entry can then bring the plugin's panel up instead of a
// dialog. The panels subscribe here while they are on screen; a request
// nobody answers (no composer open, no message open, a phone) resolves to
// null, so the plugin can fall back to a dialog.
// dialog. The app-side-panel is the panel of Calendar, Contacts, Files and
// Settings (components/plugins/app-side-panel.tsx). The panels subscribe
// here while they are on screen; a request nobody answers (no composer
// open, no message open, a phone) resolves to null, so the plugin can fall
// back to a dialog. Where the app-side-panel can't dock (phones, Settings
// below 1024 px) it answers false and asks the plugin for its dialog.
//
// When a panel opens on request, its slot gets a new `focusRequest` number
// in its props, so the plugin can put the focus where it belongs.
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const;
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel'] as const;
export type PanelSlot = typeof PANEL_SLOTS[number];
export type PanelMode = 'open' | 'close' | 'toggle';
+19 -2
View File
@@ -49,7 +49,8 @@ export function slotPropsFor(
slot: SlotName,
props: Record<string, unknown>,
): Record<string, unknown> {
const allowed = slotSpec(slot)?.props;
const spec = slotSpec(slot);
const allowed = spec?.props;
if (allowed === '*') return props;
if (!allowed) return {};
const out: Record<string, unknown> = {};
@@ -57,7 +58,23 @@ export function slotPropsFor(
if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
const perm = allowed[key];
if (perm && !pluginHasPermission(plugin, perm)) continue;
out[key] = value;
const fields = spec?.fields?.[key];
out[key] = fields ? fieldsFor(plugin, fields, value) : value;
}
return out;
}
// A prop made of parts with their own permissions (the app-side-panel's
// context: an event, a contact, files): only the parts the plugin may see,
// and none the catalogue doesn't name.
function fieldsFor(plugin: InstalledPlugin, fields: Record<string, Permission | null>, value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, unknown> = {};
for (const [field, part] of Object.entries(value as Record<string, unknown>)) {
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;
const perm = fields[field];
if (perm && !pluginHasPermission(plugin, perm)) continue;
out[field] = part;
}
return out;
}
+7 -2
View File
@@ -162,10 +162,15 @@ function applyHostFeatures(next: HostFeaturesView | undefined): void {
hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true;
// A host older than 1.20 doesn't send it, and never let such a plugin run in the mode.
hostFeaturesState.noThirdPartyTraffic = next.noThirdPartyTraffic === true;
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host names, true or false.
// The AI assistant's flags (lib/plugin-sandbox/assistant): whatever the host
// names, true or false; one it no longer names turns false.
const flags = hostFeaturesState as unknown as Record<string, boolean>;
const core = new Set(['externalSenderLabel', 'externalRecipientWarning', 'noThirdPartyTraffic']);
for (const key of Object.keys(flags)) {
if (!core.has(key) && !(key in next)) flags[key] = false;
}
for (const [key, value] of Object.entries(next)) {
if (key !== 'externalSenderLabel' && key !== 'externalRecipientWarning' && key !== 'noThirdPartyTraffic') flags[key] = value === true;
if (!core.has(key)) flags[key] = value === true;
}
for (const listener of hostFeatureListeners) {
try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); }
+5
View File
@@ -31,6 +31,11 @@ interface NormalisedCombo {
const bindings = new Map<string, Binding>(); // key: `${pluginId}:${shortcutId}`
let listenerInstalled = false;
/** The keys a plugin's shortcut is bound to ("Alt+A"), or null when it has none by that id. */
export function shortcutKeysFor(pluginId: string, shortcutId: string): string | null {
return bindings.get(`${pluginId}:${shortcutId}`)?.keys ?? null;
}
function normaliseCombo(combo: string): NormalisedCombo | null {
if (typeof combo !== 'string') return null;
const parts = combo.split('+').map(p => p.trim()).filter(Boolean);
+1 -1
View File
@@ -44,7 +44,7 @@ export function clampSlotWidth(width: unknown): number | null {
* can pin controls at the bottom, like a chat input. Elsewhere a frame grows
* with its content.
*/
export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-page'];
export const FILLABLE_SLOTS: readonly string[] = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar', 'app-side-panel', 'app-page', 'plugin-dialog'];
export function isFillableSlot(slot: string): boolean {
return FILLABLE_SLOTS.includes(slot);
+7
View File
@@ -14,6 +14,7 @@
// Badges on list rows are data too (the onEmailListItemRender hook).
import type { Permission } from './plugin-types';
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from './plugin-sandbox/assistant/f.types';
export type SlotArea =
| 'app' | 'mail-list' | 'reader' | 'composer' | 'navigation'
@@ -26,6 +27,11 @@ export interface SlotSpec {
permissions: readonly Permission[];
/** Props (frame) or click context (action) and the permission each needs; null = none. */
props: Record<string, Permission | null> | '*';
/**
* Props that are objects whose fields need their own permission: prop →
* field → permission (null = none). Fields not named are dropped.
*/
fields?: Record<string, Record<string, Permission | null>>;
/** Sits in a row of buttons and sizes to its content. */
inline?: boolean;
/** Where it appears. */
@@ -46,6 +52,7 @@ export const SLOT_CATALOG = {
'app-top-banner': S('app', 'frame', ['ui:app-top-banner'], { username: 'account:read', serverUrl: 'account:read', impersonated: 'account:read' }, 'a banner across the top of every page; `impersonated` is true when an admin opened the mailbox through an impersonation handoff'),
'plugin-dialog': S('dialog', 'frame', ['ui:observe'], '*', 'inside a dialog the plugin opened with api.ui.openDialog'),
'command-palette': S('app', 'action', ['ui:commands'], {}, 'entries in the command palette (Ctrl+K)'),
'app-side-panel': { ...S('app', 'frame', ['ui:side-panel'], { view: null, context: null, focusRequest: null }, 'a side panel next to Calendar, Contacts, Files and Settings (desktop and tablet; phones and Settings below 1024 px have none: the toggle there asks the plugin for a dialog through onAction, surface `app-side-panel`, actionId `open`, context { view, context }); `view` names the view ("calendar" | "contacts" | "files" | "settings"); `context` is what it shows: { event } (calendar:read), { contact, selectedCount } (contacts:read), { files, fileCount, folder } (files:read), { page: { id, label } }; `focusRequest` counts up when the panel opens on request'), fields: { context: APP_SIDE_PANEL_CONTEXT_FIELDS } },
// ─── Navigation ────────────────────────────────────────────
'navigation-rail-bottom': S('navigation', 'frame', ['ui:navigation-rail'], {}, 'the bottom of the navigation rail'),
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CredentialSpec } from './plugin-secrets/credentials';
import type { CatalogSlotName } from './plugin-slot-catalog';
import { ASSISTANT_PERMISSIONS, type AssistantHostFeatures } from './plugin-sandbox/assistant/types';
import type { AttachmentExtrasB } from './plugin-sandbox/assistant/b.types';
import type { AttachmentExtrasB, EmailReadViewExtrasB } from './plugin-sandbox/assistant/b.types';
// Plugin & Theme system types
@@ -397,7 +397,7 @@ export interface KeyboardShortcut {
// ─── Read-Only View Types ────────────────────────────────────
// Projected views exposed to plugins - no direct store references
export interface EmailReadView {
export interface EmailReadView extends EmailReadViewExtrasB {
id: string;
/**
* The JMAP account the message is in. Message ids repeat across accounts
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} من {total}",
"plugin_panel_show": "إظهار لوحة الإضافة",
"plugin_panel_hide": "إخفاء لوحة الإضافة",
"plugin_panel": "لوحة الإضافة",
"copy": "نسخ",
"contact_added": "أُضيفت جهة الاتصال",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "إضافة لوحات إلى تفاصيل الرسالة",
"body": "عرض لوحتها الخاصة بجانب الرسالة المفتوحة أو أسفلها."
},
"ui_side_panel": {
"title": "إضافة لوحة إلى العروض الأخرى",
"body": "عرض لوحة خاصة به بجانب التقويم وجهات الاتصال والملفات والإعدادات."
},
"ui_download_file": {
"title": "حفظ الملفات على جهازك",
"body": "إتاحة ملفات أنشأتها للتنزيل."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostra el tauler del connector",
"plugin_panel_hide": "Amaga el tauler del connector",
"plugin_panel": "Tauler del connector",
"copy": "Copia",
"contact_added": "S'ha afegit el contacte",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Afegir panells als detalls del missatge",
"body": "Mostrar un panell propi al costat o a sota d'un missatge obert."
},
"ui_side_panel": {
"title": "Afegir un tauler a altres vistes",
"body": "Mostrar el seu propi tauler al costat del Calendari, els Contactes, els Fitxers i la Configuració."
},
"ui_download_file": {
"title": "Desar fitxers al dispositiu",
"body": "Oferir per baixar fitxers que ha creat."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} z {total}",
"plugin_panel_show": "Zobrazit panel pluginu",
"plugin_panel_hide": "Skrýt panel pluginu",
"plugin_panel": "Panel pluginu",
"copy": "Kopírovat",
"contact_added": "Kontakt přidán",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Přidávat panely do podrobností zprávy",
"body": "Zobrazuje vlastní panel vedle otevřené zprávy nebo pod ní."
},
"ui_side_panel": {
"title": "Přidat panel do dalších zobrazení",
"body": "Zobrazit vlastní panel vedle Kalendáře, Kontaktů, Souborů a Nastavení."
},
"ui_download_file": {
"title": "Ukládat soubory do zařízení",
"body": "Nabízí ke stažení soubory, které vytvořil."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} af {total}",
"plugin_panel_show": "Vis pluginpanel",
"plugin_panel_hide": "Skjul pluginpanel",
"plugin_panel": "Pluginpanel",
"copy": "Kopiér",
"contact_added": "Kontakt tilføjet",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Tilføje paneler til beskeddetaljer",
"body": "Vise sit eget panel ved siden af eller under en åben besked."
},
"ui_side_panel": {
"title": "Tilføj et panel til andre visninger",
"body": "Vis sit eget panel ved siden af Kalender, Kontakter, Filer og Indstillinger."
},
"ui_download_file": {
"title": "Gemme filer på din enhed",
"body": "Tilbyde filer, det har lavet, til download."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} von {total}",
"plugin_panel_show": "Plugin-Bereich einblenden",
"plugin_panel_hide": "Plugin-Bereich ausblenden",
"plugin_panel": "Plugin-Bereich",
"copy": "Kopieren",
"contact_added": "Kontakt hinzugefügt",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Bereiche zu Nachrichtendetails hinzufügen",
"body": "Einen eigenen Bereich neben oder unter einer geöffneten Nachricht anzeigen."
},
"ui_side_panel": {
"title": "Bereiche in anderen Ansichten hinzufügen",
"body": "Einen eigenen Bereich neben Kalender, Kontakten, Dateien und Einstellungen anzeigen."
},
"ui_download_file": {
"title": "Dateien auf Ihrem Gerät speichern",
"body": "Selbst erstellte Dateien zum Herunterladen anbieten."
+5
View File
@@ -853,6 +853,7 @@
},
"plugin_panel_show": "Show plugin panel",
"plugin_panel_hide": "Hide plugin panel",
"plugin_panel": "Plugin panel",
"frame_title": "Message: {subject}",
"frame_title_from": "Message from {sender}",
"download_named": "Download {name}",
@@ -5977,6 +5978,10 @@
"title": "Add panels to message details",
"body": "Show its own panel next to or below an open message."
},
"ui_side_panel": {
"title": "Add a panel to other views",
"body": "Show its own panel next to Calendar, Contacts, Files and Settings."
},
"ui_download_file": {
"title": "Save files to your device",
"body": "Offer files it created for download."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostrar el panel del complemento",
"plugin_panel_hide": "Ocultar el panel del complemento",
"plugin_panel": "Panel del complemento",
"copy": "Copiar",
"contact_added": "Contacto añadido",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Añadir paneles a los detalles del mensaje",
"body": "Mostrar su propio panel junto a un mensaje abierto o debajo de él."
},
"ui_side_panel": {
"title": "Añadir un panel a otras vistas",
"body": "Mostrar su propio panel junto al Calendario, los Contactos, los Archivos y los Ajustes."
},
"ui_download_file": {
"title": "Guardar archivos en tu dispositivo",
"body": "Ofrecer para descargar archivos que creó."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} / {total}",
"plugin_panel_show": "Näita pistikprogrammi paneeli",
"plugin_panel_hide": "Peida pistikprogrammi paneel",
"plugin_panel": "Pistikprogrammi paneel",
"copy": "Kopeeri",
"contact_added": "Kontakt lisatud",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Lisa kirja üksikasjadele paneele",
"body": "Näidata oma paneeli avatud kirja kõrval või all."
},
"ui_side_panel": {
"title": "Lisa paneel teistesse vaadetesse",
"body": "Näita oma paneeli kalendri, kontaktide, failide ja seadete kõrval."
},
"ui_download_file": {
"title": "Salvesta faile sinu seadmesse",
"body": "Pakkuda allalaadimiseks enda loodud faile."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} از {total}",
"plugin_panel_show": "نمایش پنل افزونه",
"plugin_panel_hide": "پنهان کردن پنل افزونه",
"plugin_panel": "پنل افزونه",
"copy": "کپی",
"contact_added": "مخاطب افزوده شد",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "افزودن پنل به جزئیات پیام",
"body": "نمایش پنل خودش کنار یا زیر پیام باز."
},
"ui_side_panel": {
"title": "افزودن پنل به نماهای دیگر",
"body": "نمایش پنل خودش در کنار تقویم، مخاطبان، فایل‌ها و تنظیمات."
},
"ui_download_file": {
"title": "ذخیره فایل در دستگاه شما",
"body": "ارائه فایل‌هایی که خودش ساخته است برای دانلود."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} sur {total}",
"plugin_panel_show": "Afficher le panneau du plugin",
"plugin_panel_hide": "Masquer le panneau du plugin",
"plugin_panel": "Panneau du plugin",
"copy": "Copier",
"contact_added": "Contact ajouté",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Ajouter des panneaux aux détails des messages",
"body": "Afficher son propre panneau à côté ou en dessous d'un message ouvert."
},
"ui_side_panel": {
"title": "Ajouter un panneau aux autres vues",
"body": "Afficher son propre panneau à côté de l’Agenda, des Contacts, des Fichiers et des Paramètres."
},
"ui_download_file": {
"title": "Enregistrer des fichiers sur votre appareil",
"body": "Proposer au téléchargement des fichiers qu'il a créés."
+5
View File
@@ -808,6 +808,7 @@
"mv_position": "{index} מתוך {total}",
"plugin_panel_show": "הצגת חלונית התוסף",
"plugin_panel_hide": "הסתרת חלונית התוסף",
"plugin_panel": "חלונית התוסף",
"copy": "העתק",
"contact_added": "איש הקשר נוסף",
"save_to_files": {
@@ -5978,6 +5979,10 @@
"title": "הוספת חלוניות לפרטי ההודעה",
"body": "הצגת חלונית משלו לצד הודעה פתוחה או מתחתיה."
},
"ui_side_panel": {
"title": "הוספת חלונית לתצוגות אחרות",
"body": "הצגת חלונית משלו לצד היומן, אנשי הקשר, הקבצים וההגדרות."
},
"ui_download_file": {
"title": "שמירת קבצים במכשיר שלכם",
"body": "הצעת קבצים שהוא יצר להורדה."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} / {total}",
"plugin_panel_show": "Bővítménypanel megjelenítése",
"plugin_panel_hide": "Bővítménypanel elrejtése",
"plugin_panel": "Bővítménypanel",
"copy": "Másolás",
"contact_added": "Névjegy hozzáadva",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Panelek az üzenet részleteiben",
"body": "Saját panelt jelenít meg egy megnyitott üzenet mellett vagy alatt."
},
"ui_side_panel": {
"title": "Panel hozzáadása más nézetekhez",
"body": "Saját panel megjelenítése a Naptár, a Névjegyek, a Fájlok és a Beállítások mellett."
},
"ui_download_file": {
"title": "Fájlok mentése az eszközödre",
"body": "Letöltésre kínálja az általa létrehozott fájlokat."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} di {total}",
"plugin_panel_show": "Mostra il pannello del plugin",
"plugin_panel_hide": "Nascondi il pannello del plugin",
"plugin_panel": "Pannello del plugin",
"copy": "Copia",
"contact_added": "Contatto aggiunto",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Aggiungere pannelli ai dettagli del messaggio",
"body": "Mostrare un proprio pannello accanto o sotto un messaggio aperto."
},
"ui_side_panel": {
"title": "Aggiungere un pannello ad altre viste",
"body": "Mostrare un proprio pannello accanto a Calendario, Contatti, File e Impostazioni."
},
"ui_download_file": {
"title": "Salvare file sul tuo dispositivo",
"body": "Offrire in download i file che ha creato."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} / {total}",
"plugin_panel_show": "プラグインパネルを表示",
"plugin_panel_hide": "プラグインパネルを隠す",
"plugin_panel": "プラグインパネル",
"copy": "コピー",
"contact_added": "連絡先を追加しました",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "メッセージの詳細にパネルを追加",
"body": "開いたメッセージの横または下に独自のパネルを表示します。"
},
"ui_side_panel": {
"title": "ほかの画面にパネルを追加",
"body": "カレンダー、連絡先、ファイル、設定の横に独自のパネルを表示します。"
},
"ui_download_file": {
"title": "デバイスにファイルを保存",
"body": "作成したファイルをダウンロードできるようにします。"
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} / {total}",
"plugin_panel_show": "플러그인 패널 표시",
"plugin_panel_hide": "플러그인 패널 숨기기",
"plugin_panel": "플러그인 패널",
"copy": "복사",
"contact_added": "연락처를 추가했어요",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "메시지 세부 정보에 패널 추가",
"body": "열어 본 메시지 옆이나 아래에 자체 패널을 표시해요."
},
"ui_side_panel": {
"title": "다른 보기에 패널 추가",
"body": "캘린더, 연락처, 파일, 설정 옆에 자체 패널을 표시합니다."
},
"ui_download_file": {
"title": "기기에 파일 저장",
"body": "직접 만든 파일을 다운로드할 수 있게 제공해요."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} no {total}",
"plugin_panel_show": "Rādīt spraudņa paneli",
"plugin_panel_hide": "Slēpt spraudņa paneli",
"plugin_panel": "Spraudņa panelis",
"copy": "Kopēt",
"contact_added": "Kontakts pievienots",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Pievienot paneļus vēstules informācijai",
"body": "Rādīt savu paneli blakus atvērtai vēstulei vai zem tās."
},
"ui_side_panel": {
"title": "Pievienot paneli citiem skatiem",
"body": "Rādīt savu paneli blakus kalendāram, kontaktiem, failiem un iestatījumiem."
},
"ui_download_file": {
"title": "Saglabāt failus jūsu ierīcē",
"body": "Piedāvāt lejupielādei paša izveidotus failus."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{total}-с {index}",
"plugin_panel_show": "Залгаасын самбарыг харуулах",
"plugin_panel_hide": "Залгаасын самбарыг нуух",
"plugin_panel": "Залгаасын самбар",
"copy": "Хуулах",
"contact_added": "Харилцагч нэмэгдлээ",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Имэйлийн дэлгэрэнгүйд самбар нэмэх",
"body": "Нээлттэй имэйлийн хажууд эсвэл доор өөрийн самбарыг харуулах."
},
"ui_side_panel": {
"title": "Бусад харагдацад самбар нэмэх",
"body": "Хуанли, Харилцагчид, Файлууд, Тохиргооны хажууд өөрийн самбарыг харуулна."
},
"ui_download_file": {
"title": "Файлыг таны төхөөрөмжид хадгалах",
"body": "Өөрийн үүсгэсэн файлуудыг татаж авахыг санал болгох."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} av {total}",
"plugin_panel_show": "Vis tilleggspanel",
"plugin_panel_hide": "Skjul tilleggspanel",
"plugin_panel": "Tilleggspanel",
"copy": "Kopier",
"contact_added": "Kontakt lagt til",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Legge til paneler i meldingsdetaljene",
"body": "Vise et eget panel ved siden av eller under en åpen melding."
},
"ui_side_panel": {
"title": "Legg til et panel i andre visninger",
"body": "Vis sitt eget panel ved siden av Kalender, Kontakter, Filer og Innstillinger."
},
"ui_download_file": {
"title": "Lagre filer på enheten din",
"body": "Tilby filer det har laget, for nedlasting."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} van {total}",
"plugin_panel_show": "Plug-inpaneel tonen",
"plugin_panel_hide": "Plug-inpaneel verbergen",
"plugin_panel": "Plug-inpaneel",
"copy": "Kopiëren",
"contact_added": "Contact toegevoegd",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Panelen toevoegen aan berichtdetails",
"body": "Een eigen paneel tonen naast of onder een geopend bericht."
},
"ui_side_panel": {
"title": "Een paneel toevoegen aan andere weergaven",
"body": "Een eigen paneel tonen naast Agenda, Contacten, Bestanden en Instellingen."
},
"ui_download_file": {
"title": "Bestanden op je apparaat opslaan",
"body": "Bestanden die hij heeft gemaakt aanbieden om te downloaden."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} z {total}",
"plugin_panel_show": "Pokaż panel wtyczki",
"plugin_panel_hide": "Ukryj panel wtyczki",
"plugin_panel": "Panel wtyczki",
"copy": "Kopiuj",
"contact_added": "Dodano kontakt",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Panele w szczegółach wiadomości",
"body": "Pokazuje własny panel obok lub pod otwartą wiadomością."
},
"ui_side_panel": {
"title": "Dodawanie panelu do innych widoków",
"body": "Wyświetlanie własnego panelu obok Kalendarza, Kontaktów, Plików i Ustawień."
},
"ui_download_file": {
"title": "Zapisywanie plików na urządzeniu",
"body": "Udostępnia do pobrania utworzone przez siebie pliki."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostrar o painel do plugin",
"plugin_panel_hide": "Ocultar o painel do plugin",
"plugin_panel": "Painel do plugin",
"copy": "Copiar",
"contact_added": "Contacto adicionado",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Adicionar painéis aos detalhes da mensagem",
"body": "Mostrar o próprio painel ao lado ou abaixo de uma mensagem aberta."
},
"ui_side_panel": {
"title": "Adicionar um painel a outras vistas",
"body": "Mostrar o seu próprio painel ao lado do Calendário, Contactos, Ficheiros e Definições."
},
"ui_download_file": {
"title": "Salvar arquivos no seu dispositivo",
"body": "Oferecer para download arquivos que ele criou."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} din {total}",
"plugin_panel_show": "Afișează panoul pluginului",
"plugin_panel_hide": "Ascunde panoul pluginului",
"plugin_panel": "Panoul pluginului",
"copy": "Copiază",
"contact_added": "Contact adăugat",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Adăugarea de panouri la detaliile mesajului",
"body": "Afișează un panou propriu lângă sau sub un mesaj deschis."
},
"ui_side_panel": {
"title": "Adăugarea unui panou în alte vizualizări",
"body": "Afișarea propriului panou lângă Calendar, Contacte, Fișiere și Setări."
},
"ui_download_file": {
"title": "Salvarea fișierelor pe dispozitiv",
"body": "Oferă spre descărcare fișierele create de el."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} из {total}",
"plugin_panel_show": "Показать панель плагина",
"plugin_panel_hide": "Скрыть панель плагина",
"plugin_panel": "Панель плагина",
"copy": "Копировать",
"contact_added": "Контакт добавлен",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Добавлять панели к письму",
"body": "Показывать свою панель рядом с открытым письмом или под ним."
},
"ui_side_panel": {
"title": "Добавлять панель в другие разделы",
"body": "Показывать свою панель рядом с Календарём, Контактами, Файлами и Настройками."
},
"ui_download_file": {
"title": "Сохранять файлы на ваше устройство",
"body": "Предлагать для скачивания созданные им файлы."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} z {total}",
"plugin_panel_show": "Zobraziť panel doplnku",
"plugin_panel_hide": "Skryť panel doplnku",
"plugin_panel": "Panel doplnku",
"copy": "Kopírovať",
"contact_added": "Kontakt pridaný",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Pridávať panely do podrobností správy",
"body": "Zobrazuje vlastný panel vedľa otvorenej správy alebo pod ňou."
},
"ui_side_panel": {
"title": "Pridať panel do ďalších zobrazení",
"body": "Zobraziť vlastný panel vedľa Kalendára, Kontaktov, Súborov a Nastavení."
},
"ui_download_file": {
"title": "Ukladať súbory do zariadenia",
"body": "Ponúka na stiahnutie súbory, ktoré vytvoril."
+5
View File
@@ -1929,6 +1929,7 @@
},
"plugin_panel_show": "แสดงแถบปลั๊กอิน",
"plugin_panel_hide": "ซ่อนแถบปลั๊กอิน",
"plugin_panel": "แถบปลั๊กอิน",
"frame_title": "ข้อความ: {subject}",
"frame_title_from": "ข้อความจาก {sender}",
"download_named": "ดาวน์โหลด {name}",
@@ -2915,6 +2916,10 @@
"title": "เพิ่มแผงข้อมูลในรายละเอียดข้อความ",
"body": "แสดงแผงควบคุมของปลั๊กอินเองถัดจากหรือด้านล่างของข้อความที่เปิดอยู่"
},
"ui_side_panel": {
"title": "เพิ่มแผงในมุมมองอื่น",
"body": "แสดงแผงของตัวเองข้างปฏิทิน รายชื่อติดต่อ ไฟล์ และการตั้งค่า"
},
"ui_download_file": {
"title": "บันทึกไฟล์ลงในอุปกรณ์ของคุณ",
"body": "เปิดให้ดาวน์โหลดไฟล์ที่ปลั๊กอินสร้างขึ้น"
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} / {total}",
"plugin_panel_show": "Eklenti panelini göster",
"plugin_panel_hide": "Eklenti panelini gizle",
"plugin_panel": "Eklenti paneli",
"copy": "Kopyala",
"contact_added": "Kişi eklendi",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "İleti ayrıntılarına panel ekleme",
"body": "Açık bir iletinin yanında veya altında kendi panelini gösterme."
},
"ui_side_panel": {
"title": "Diğer görünümlere panel ekleme",
"body": "Takvim, Kişiler, Dosyalar ve Ayarlar'ın yanında kendi panelini gösterme."
},
"ui_download_file": {
"title": "Dosyaları cihazınıza kaydetme",
"body": "Oluşturduğu dosyaları indirmeniz için sunma."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "{index} з {total}",
"plugin_panel_show": "Показати панель плагіна",
"plugin_panel_hide": "Сховати панель плагіна",
"plugin_panel": "Панель плагіна",
"copy": "Копіювати",
"contact_added": "Контакт додано",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "Додавати панелі до листа",
"body": "Показувати власну панель поруч із відкритим листом або під ним."
},
"ui_side_panel": {
"title": "Додавати панель до інших розділів",
"body": "Показувати власну панель поруч із Календарем, Контактами, Файлами та Налаштуваннями."
},
"ui_download_file": {
"title": "Зберігати файли на ваш пристрій",
"body": "Пропонувати для завантаження створені ним файли."
+5
View File
@@ -853,6 +853,7 @@
},
"plugin_panel_show": "Hiện bảng plugin",
"plugin_panel_hide": "Ẩn bảng plugin",
"plugin_panel": "Bảng plugin",
"frame_title": "Thư: {subject}",
"frame_title_from": "Thư từ {sender}",
"download_named": "Tải xuống {name}",
@@ -5989,6 +5990,10 @@
"title": "Thêm bảng vào chi tiết thư",
"body": "Hiển thị bảng riêng bên cạnh hoặc bên dưới thư đang mở."
},
"ui_side_panel": {
"title": "Thêm bảng vào các chế độ xem khác",
"body": "Hiển thị bảng riêng bên cạnh Lịch, Danh bạ, Tệp và Cài đặt."
},
"ui_download_file": {
"title": "Lưu tệp vào thiết bị",
"body": "Cho phép tải xuống các tệp plugin đã tạo."
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "第 {index} 封,共 {total} 封",
"plugin_panel_show": "顯示外掛程式面板",
"plugin_panel_hide": "隱藏外掛程式面板",
"plugin_panel": "外掛程式面板",
"copy": "複製",
"contact_added": "已新增聯絡人",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "在郵件詳細資料新增面板",
"body": "在開啟的郵件旁邊或下方顯示它自己的面板。"
},
"ui_side_panel": {
"title": "在其他檢視中加入面板",
"body": "在行事曆、聯絡人、檔案和設定旁顯示自己的面板。"
},
"ui_download_file": {
"title": "將檔案儲存到您的裝置",
"body": "提供它建立的檔案供下載。"
+5
View File
@@ -843,6 +843,7 @@
"mv_position": "第 {index} 封,共 {total} 封",
"plugin_panel_show": "显示插件面板",
"plugin_panel_hide": "隐藏插件面板",
"plugin_panel": "插件面板",
"copy": "复制",
"contact_added": "已添加联系人",
"save_to_files": {
@@ -5977,6 +5978,10 @@
"title": "在邮件详情中添加面板",
"body": "在打开的邮件旁边或下方显示它自己的面板。"
},
"ui_side_panel": {
"title": "在其他视图中添加面板",
"body": "在日历、联系人、文件和设置旁显示自己的面板。"
},
"ui_download_file": {
"title": "将文件保存到您的设备",
"body": "提供它创建的文件供下载。"