Merge branch 'fix/plugin-surfaces' into feat/plugin-system-audit

This commit is contained in:
Linus Rath
2026-10-14 10:19:17 +02:00
26 changed files with 1082 additions and 193 deletions
@@ -56,6 +56,47 @@ describe('calendar-event-detail', () => {
expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
});
it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => {
let resize: (() => void) | null = null;
vi.stubGlobal('ResizeObserver', class {
constructor(cb: () => void) { resize = cb; }
observe() {}
unobserve() {}
disconnect() {}
});
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
let height = 200;
const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'dialog' ? height : 0;
});
try {
offerSlots('p1', ['calendar-event-detail']);
// An event at 14:00 in the week view of a 1280x800 window.
render(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(600, 520, 120, 60)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
const popover = screen.getByRole('dialog');
expect(popover.style.top).toBe('520px');
height = 420;
act(() => resize?.());
expect(popover.style.top).toBe(`${800 - 420 - 12}px`);
} finally {
spy.mockRestore();
vi.unstubAllGlobals();
}
});
it('shows the event in the modal view, and not in the form', () => {
offerSlots('p1', ['calendar-event-detail']);
render(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
@@ -153,18 +194,27 @@ describe('calendar-day-header and mini-calendar-marks', () => {
expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
});
it('shows the label in the month cell header on desktop only', () => {
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
it('shows the label in the month cell header, as a dot on phones', () => {
act(() => setDayLabels('hol', 'Holidays', [
{ date: '2026-09-09', text: 'Feast day' },
{ date: '2026-09-10', dot: true, title: 'Dot only' },
]));
const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
const { unmount } = render(<CalendarMonthView {...month} />);
const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
expect(labels).toHaveLength(1);
expect(labels[0]).toHaveTextContent('Feast day');
expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
unmount();
render(<CalendarMonthView {...month} isMobile />);
expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
const dots = [...document.querySelectorAll<HTMLElement>('[data-plugin-surface="calendar-day-header"]')];
expect(dots).toHaveLength(2);
for (const dot of dots) expect(dot).toHaveAttribute('data-compact');
expect(dots[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
expect(dots[0].title).toBe('Feast day (Holidays)');
expect(dots[1].title).toBe('Dot only (Holidays)');
});
it('marks days with a dot in the small month calendar', () => {
@@ -183,4 +233,22 @@ describe('calendar-day-header and mini-calendar-marks', () => {
expect(dots[0].closest('button')).toHaveTextContent('9');
expect(dots[0].closest('button')).toHaveTextContent('A feast');
});
it('keeps the dot visible on today, whose filled circle has white text', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 8, 9, 12));
try {
act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', dot: true, title: 'A feast' }]));
render(
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
);
const dot = document.querySelector<HTMLElement>('[data-plugin-surface="mini-calendar-marks"]')!;
expect(dot.closest('button')).toHaveAttribute('aria-current', 'date');
// Its own colour, not the button's text colour, which is white on today.
expect(dot.className).not.toMatch(/bg-current/);
expect(dot.className).toMatch(/ring-background/);
} finally {
vi.useRealTimers();
}
});
});
+2 -1
View File
@@ -380,7 +380,8 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
</button>
{!isMobile && <PluginDayLabel surface="calendar-day-header" date={key} className="min-w-0 flex-1" />}
{/* Phones have no room for the text: a dot stands in for it. */}
<PluginDayLabel surface="calendar-day-header" date={key} compact={isMobile} className={isMobile ? undefined : "min-w-0 flex-1"} />
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
+22 -4
View File
@@ -54,13 +54,18 @@ const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
/** As tall as MAX_HEIGHT, but never taller than the window. */
function maxPopoverHeight(): number {
return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN));
}
function computePosition(
anchorRect: DOMRect,
popoverHeight: number
): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
const clampedHeight = Math.min(popoverHeight, maxPopoverHeight());
const clampTop = (top: number) =>
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
@@ -226,6 +231,19 @@ export function EventDetailPopover({
if (!ready) requestAnimationFrame(() => setReady(true));
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
// Content that grows after the popover was placed (a plugin section reports
// its height a moment later) moves it again, so it stays in the window.
useEffect(() => {
const node = popoverRef.current;
if (!node || isMobile || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const next = computePosition(anchorRect, node.offsetHeight);
setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next));
});
observer.observe(node);
return () => observer.disconnect();
}, [anchorRect, isMobile]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -302,14 +320,14 @@ export function EventDetailPopover({
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
isMobile
? "inset-0 rounded-none flex flex-col"
: "rounded-lg"
: "rounded-lg flex flex-col"
)}
style={isMobile ? {
opacity: 1,
transform: "none",
} : {
width: POPOVER_WIDTH,
maxHeight: MAX_HEIGHT,
maxHeight: maxPopoverHeight(),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: ready ? 1 : 0,
@@ -413,7 +431,7 @@ export function EventDetailPopover({
{/* Content */}
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
isMobile ? "flex-1" : "min-h-0"
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">
+24 -2
View File
@@ -27,10 +27,16 @@ interface PluginDayLabelProps {
/** YYYY-MM-DD */
date: string;
className?: string;
/**
* calendar-day-header only: no room for text (the phone month view), so a
* dot stands for the day's labels; their text is its tooltip and is read
* out by screen readers.
*/
compact?: boolean;
}
/** What plugins put on a calendar day. Renders nothing without labels. */
export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
export function PluginDayLabel({ surface, date, className, compact }: PluginDayLabelProps) {
const labels = useDayLabels(date);
if (surface === "mini-calendar-marks") {
@@ -42,13 +48,29 @@ export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps
aria-hidden="true"
data-plugin-surface="mini-calendar-marks"
title={tooltip(marks)}
className={cn("pointer-events-none absolute top-0.5 end-0.5 h-1 w-1 rounded-full bg-current opacity-70", className)}
className={cn("pointer-events-none absolute top-0 end-0 h-1.5 w-1.5 rounded-full bg-muted-foreground ring-1 ring-background", className)}
/>
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
</>
);
}
if (compact) {
const shown = labels.filter((l) => l.text || l.dot);
if (shown.length === 0) return null;
return (
<span
data-plugin-surface="calendar-day-header"
data-compact=""
title={tooltip(shown)}
className={cn("inline-flex flex-shrink-0", className)}
>
<span aria-hidden="true" className="block h-1.5 w-1.5 rounded-full bg-muted-foreground" />
<span className="sr-only">{shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}</span>
</span>
);
}
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { useSettingsStore } from '@/stores/settings-store';
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
// Plugin surfaces of the composer: frames above and below the message text
// (fed the draft, at most every 2 seconds), and entries in the Send and
@@ -280,6 +281,15 @@ describe('composer-above-body and composer-below-body', () => {
expect(typeof props.insertText).toBe('function');
});
it('put the attachment source in the plugin button row, where the catalogue says it is', () => {
offerSlots('p1', ['composer-attachment-source']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const frame = screen.getByTestId('slot-composer-attachment-source');
expect(frame.closest('[data-composer-plugin-row]')).not.toBeNull();
expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin button row/);
});
it('draw nothing without a plugin', () => {
const { container } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
@@ -344,3 +354,70 @@ describe('composer-attach-menu', () => {
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
});
});
describe('composer-sidebar and composer-sidebar-right', () => {
function stubWidth(width: number) {
class FixedWidthObserver {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
if ((el as HTMLElement).dataset.testid !== 'email-composer') return;
this.cb([{ target: el, contentRect: { width, height: 700 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', FixedWidthObserver);
}
const panel = (side: 'left' | 'right') => document.querySelector<HTMLElement>(`[data-composer-sidebar="${side}"]`)!;
afterEach(() => {
vi.unstubAllGlobals();
try {
localStorage.removeItem('composerPluginSidebarCollapsed:left');
localStorage.removeItem('composerPluginSidebarCollapsed:right');
} catch { /* ignore */ }
});
it('give both panels a fixed width beside a wide composer', () => {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
for (const side of ['left', 'right'] as const) {
expect(panel(side).dataset.composerSidebarMode).toBe('docked');
expect(panel(side).style.width).toBe('260px');
}
expect(panel('left')).toContainElement(screen.getByTestId('slot-composer-sidebar'));
expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right'));
});
it('fold the right panel, then both, when the composer would get too narrow', () => {
stubWidth(900);
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
const { unmount } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('left').dataset.composerSidebarMode).toBe('docked');
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
unmount();
// The reading pane of a 1280 px window: an open panel would leave the
// composer too narrow for its bottom bar.
stubWidth(792);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('left').dataset.composerSidebarMode).toBe('folded');
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
});
it('open a folded panel over the composer when there is no room beside it', () => {
stubWidth(600);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
expect(panel('right').className).toContain('absolute');
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
});
});
@@ -155,6 +155,41 @@ describe('message-bulk-actions', () => {
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
it('puts them behind one "more" button when the list is narrow', async () => {
// The list next to an open folder sidebar, or on a phone.
class NarrowObserver {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
this.cb([{ target: el, contentRect: { width: 384, height: 48 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NarrowObserver);
try {
const emails = [mail('a'), mail('b')];
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'b']) });
act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [
{ id: 'make', label: 'Make tasks' },
{ id: 'more', label: 'Remind me' },
]));
render(<EmailList emails={emails} />);
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
// "Clear selection" keeps its name for screen readers but drops the text.
const clear = screen.getByRole('button', { name: 'batch_actions.clear_selection' });
expect(clear).not.toHaveTextContent('batch_actions.clear_selection');
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
const item = await screen.findByRole('menuitem', { name: 'Make tasks' });
expect(screen.getByRole('menuitem', { name: 'Remind me' })).toBeInTheDocument();
await act(async () => { fireEvent.click(item); });
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'b'] });
} finally {
vi.unstubAllGlobals();
}
});
it('adds them to the Mountain View toolbar too', async () => {
const emails = [mail('a'), mail('b')];
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
@@ -82,6 +82,60 @@ afterEach(() => {
setActionDispatcher(null);
});
describe('email-detail-sidebar', () => {
// Reports a fixed width for whatever it observes, like a 584 px reading pane
// (three panes on a laptop).
function stubWidth(width: number) {
class FixedWidthObserver {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
this.cb([{ target: el, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', FixedWidthObserver);
}
afterEach(() => {
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
try { localStorage.removeItem('emailDetailSidebarCollapsed'); } catch { /* ignore */ }
});
it('starts collapsed when the message opens into a pane too narrow for it', () => {
stubWidth(584);
offerSlots('p1', ['email-detail-sidebar']);
// The reader mounts empty first, then a message opens.
const { rerender } = render(
<EmailViewer email={null} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />,
);
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();
});
it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
stubWidth(584);
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
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');
});
it('docks next to the message when there is room', () => {
stubWidth(1000);
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('docked');
});
});
describe('reader-header and reader-below-subject', () => {
it('put the frames above the subject and under the sender, with the message', () => {
offerSlots('p1', ['reader-header', 'reader-below-subject']);
@@ -105,6 +159,14 @@ describe('reader-header and reader-below-subject', () => {
});
});
describe('email-footer', () => {
it('hands the frame the id of the open message', () => {
offerSlots('p1', ['email-footer']);
viewer(textMail('Hi'));
expect(slotProps(screen.getByTestId('slot-email-footer'))).toEqual({ emailId: 'e1' });
});
});
describe('reader-above-quote', () => {
it('sits between the new text of a plain-text reply and its folded quote', () => {
offerSlots('p1', ['reader-above-quote']);
@@ -116,6 +178,11 @@ describe('reader-above-quote', () => {
expect(follows(frame, quote)).toBe(true);
expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true);
expect((slotProps(frame).email as { id: string }).id).toBe('e1');
// The frame renders inside the anchor, so the anchor must grow with it
// instead of letting the frame spill over the quote toggle and the footer.
const anchor = container.querySelector<HTMLElement>(`[${QUOTE_ANCHOR_ATTR}]`)!;
expect(anchor.contains(frame)).toBe(true);
expect(anchor.style.height).toBe('');
});
it('is not mounted for a message without a quote, or without a plugin', () => {
@@ -196,6 +263,48 @@ describe('thread-summary', () => {
});
});
describe('reader surfaces in the phone conversation view', () => {
const conversation = [
textMail('Second', { id: 'm2', subject: 'Re: Plan', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial<Email>),
];
const thread = { threadKey: 't1', latestEmail: conversation[0], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
it('mounts the single reader\'s frames on the unfolded message only', () => {
offerSlots('p1', ['reader-header', 'reader-below-subject', 'email-banner', 'email-footer']);
render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
// The latest message is unfolded, the older one (read) is folded.
for (const slot of ['reader-header', 'reader-below-subject', 'email-banner'] as const) {
const frames = screen.getAllByTestId(`slot-${slot}`);
expect(frames).toHaveLength(1);
expect((slotProps(frames[0]).email as { id: string }).id).toBe('m2');
}
const footer = screen.getAllByTestId('slot-email-footer');
expect(footer).toHaveLength(1);
expect(slotProps(footer[0])).toEqual({ emailId: 'm2' });
// Header on top, the body between banner and footer.
expect(follows(screen.getByTestId('slot-reader-header'), screen.getByTestId('slot-reader-below-subject'))).toBe(true);
expect(follows(screen.getByTestId('slot-email-banner'), screen.getByText('Second'))).toBe(true);
expect(follows(screen.getByText('Second'), footer[0])).toBe(true);
});
it('puts plugin reader actions behind a more button on the unfolded message', async () => {
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['inbox'] }]));
const { unmount } = render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} mailboxRole="inbox" />);
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
const item = await screen.findByRole('menuitem', { name: 'Translate' });
await act(async () => { fireEvent.click(item); });
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'm2' });
unmount();
// Kept to the folder roles the plugin asked for.
render(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} mailboxRole="junk" />);
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
});
});
describe('reader-actions', () => {
it('adds the plugin buttons to the reader toolbar', async () => {
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
@@ -230,6 +339,40 @@ describe('reader-actions', () => {
});
});
describe('attachment-actions', () => {
const withAttachment = () => textMail('See attached', {
hasAttachment: true,
attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }],
} as Partial<Email>);
it('puts the frames next to the chip, not in its hover overlay', () => {
offerSlots('p1', ['attachment-actions']);
viewer(withAttachment());
// Desktop chip and phone chip are both in the DOM.
const frames = screen.getAllByTestId('slot-attachment-actions');
expect(frames.length).toBeGreaterThan(0);
for (const frame of frames) {
// Nothing between the frame and the page may hide it until hover.
for (let el: HTMLElement | null = frame; el; el = el.parentElement) {
expect(el.className).not.toMatch(/\bopacity-0\b/);
}
expect(frame.closest('[data-attachment-name]')).toBeNull();
expect(slotProps(frame)).toEqual({
attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' },
});
}
});
it('keeps the invisible hover overlay from taking taps', () => {
viewer(withAttachment());
const overlays = [...document.querySelectorAll<HTMLElement>('[data-attachment-name="invoice.pdf"] div')]
.filter((d) => /\bopacity-0\b/.test(d.className));
expect(overlays.length).toBeGreaterThan(0);
for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/);
});
});
describe('attachment-menu', () => {
const withAttachments = () => textMail('See attached', {
hasAttachment: true,
@@ -0,0 +1,189 @@
"use client";
// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a
// fixed width and a toggle to fold it to a thin strip. When the composer is
// too narrow for an open panel next to it, the panel folds by itself (right
// first); opening it anyway lays it over the composer instead of squeezing
// the message into a column nobody can write in.
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { cn } from "@/lib/utils";
/** Width of an open panel. */
export const COMPOSER_SIDEBAR_WIDTH = 260;
/** Width of a folded panel (the strip with its toggle). */
export const COMPOSER_SIDEBAR_STRIP = 37;
/**
* Narrowest the composer itself may get next to open panels: below this its
* bottom bar (Attach, Templates, Options, Discard, Send) runs into itself.
*/
export const MIN_COMPOSER_WIDTH = 560;
export type ComposerSidebarSide = "left" | "right";
/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */
export type ComposerSidebarMode = "docked" | "folded" | "overlay";
interface SideState {
present: boolean;
/** Folded by the user. */
collapsed: boolean;
/** Opened by the user while there was no room for it. */
overlayOpen?: boolean;
}
/**
* How each panel is shown in a composer `width` px wide (0 = not measured
* yet: everything the user left open is shown open). The left panel gets
* room first, since the right one is folded first.
*/
export function composerSidebarModes(
width: number,
left: SideState,
right: SideState,
): Record<ComposerSidebarSide, ComposerSidebarMode | null> {
const present = (left.present ? 1 : 0) + (right.present ? 1 : 0);
let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity;
const place = (side: SideState): ComposerSidebarMode | null => {
if (!side.present) return null;
if (side.collapsed) return "folded";
const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP;
if (extra <= room) {
room -= extra;
return "docked";
}
return side.overlayOpen ? "overlay" : "folded";
};
const l = place(left);
return { left: l, right: place(right) };
}
const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
function readCollapsed(side: ComposerSidebarSide): boolean {
if (typeof window === "undefined") return false;
try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
}
/** Layout state of both panels for a composer whose root element is `root`. */
export function useComposerSidebars(root: HTMLElement | null) {
const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
const [width, setWidth] = useState(0);
const [collapsed, setCollapsed] = useState<Record<ComposerSidebarSide, boolean>>(() => ({
left: readCollapsed("left"),
right: readCollapsed("right"),
}));
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ left: false, right: false });
useEffect(() => {
if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width));
});
observer.observe(root);
return () => observer.disconnect();
}, [root, hasLeft, hasRight]);
const modes = composerSidebarModes(
width,
{ present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left },
{ present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right },
);
const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
setCollapsed((prev) => ({ ...prev, [side]: value }));
try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
};
/** Open a folded panel: beside the composer when it fits, over it otherwise. */
const open = (side: ComposerSidebarSide) => {
const next = { ...collapsed, [side]: false };
const fits = composerSidebarModes(
width,
{ present: hasLeft, collapsed: next.left },
{ present: hasRight, collapsed: next.right },
)[side] === "docked";
setSideCollapsed(side, false);
setOverlayOpen((prev) => ({ ...prev, [side]: !fits }));
};
const close = (side: ComposerSidebarSide) => {
setSideCollapsed(side, true);
setOverlayOpen((prev) => ({ ...prev, [side]: false }));
};
return { modes, open, close };
}
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
export function ComposerPluginSidebar({
side,
mode,
onOpen,
onClose,
}: {
side: ComposerSidebarSide;
mode: ComposerSidebarMode | null;
onOpen: () => void;
onClose: () => void;
}) {
const t = useTranslations("email_viewer");
if (!mode) return null;
const border = side === "left" ? "border-e" : "border-s";
const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose;
if (mode === "folded") {
return (
<div
className={cn("hidden md:flex flex-col items-center shrink-0 h-full border-border bg-background", border)}
style={{ width: COMPOSER_SIDEBAR_STRIP }}
data-composer-sidebar={side}
data-composer-sidebar-mode="folded"
>
<button
type="button"
onClick={onOpen}
className="m-1 grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_show")}
aria-label={t("plugin_panel_show")}
>
<OpenIcon className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className={cn(
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
border,
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
)}
style={{ width: COMPOSER_SIDEBAR_WIDTH }}
data-composer-sidebar={side}
data-composer-sidebar-mode={mode}
>
<div className={cn("flex items-center px-1 py-1 border-b border-border shrink-0", side === "left" ? "justify-end" : "justify-start")}>
<button
type="button"
onClick={onClose}
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_hide")}
aria-label={t("plugin_panel_hide")}
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{side === "left"
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
</div>
</div>
);
}
+8 -9
View File
@@ -61,6 +61,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginActions } from "@/components/plugins/plugin-actions";
@@ -2071,6 +2072,9 @@ export function EmailComposer({
// Stable props, so a re-render of the composer doesn't post them to the
// plugin's iframe again.
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl);
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
@@ -3222,14 +3226,12 @@ export function EmailComposer({
return (
<div
ref={setComposerRootEl}
data-testid="email-composer"
className={cn("flex h-full bg-background", className)}
className={cn("flex h-full bg-background relative", className)}
onKeyDownCapture={handleComposerKeyDown}
>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
/>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -4335,10 +4337,7 @@ export function EmailComposer({
/>
)}
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
/>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
</div>
);
}
+28 -8
View File
@@ -9,7 +9,7 @@ import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
import { BatchTagButton } from "./batch-tag-button";
import { cn, cleanPreview } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -75,6 +75,9 @@ interface EmailListProps {
hideBatchToolbar?: boolean;
}
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 480;
export function EmailList({
emails,
selectedEmailId,
@@ -267,6 +270,20 @@ export function EmailList({
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
// A narrow list (folder sidebar open, phones) has no room for the batch
// toolbar's labels: plugin buttons go behind one "more" button and "Clear
// selection" becomes an icon, instead of wrapping into each other.
const [batchNarrow, setBatchNarrow] = useState(false);
useEffect(() => {
const toolbar = batchToolbarRef.current;
if (!toolbar || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? toolbar.getBoundingClientRect().width;
if (width > 0) setBatchNarrow(width < BATCH_TOOLBAR_WIDE);
});
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
// One tag treatment for the whole list, measured from the scroll container.
const tagDisplay = useMeasuredTagDisplay(parentRef);
const density = useSettingsStore((state) => state.density);
@@ -594,13 +611,13 @@ export function EmailList({
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
<div className={cn("py-2 border-b bg-accent/30 border-border flex items-center justify-between gap-2", batchNarrow ? "px-2" : "px-4")} data-batch-narrow={batchNarrow ? "" : undefined}>
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate">
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0.5 [&_button]:px-2" : "gap-1")}>
<Button
variant="ghost"
size="sm"
@@ -674,18 +691,21 @@ export function EmailList({
context={bulkActionContext}
mailboxRole={effectiveMailboxRole ?? null}
compact
overflow={batchNarrow}
className="flex-nowrap"
/>
)}
<div className="w-px h-6 bg-border mx-1" />
<div className={cn("w-px h-6 bg-border", batchNarrow ? "mx-0.5" : "mx-1")} />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title={t('batch_actions.clear_selection')}
aria-label={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 whitespace-nowrap"
>
{t('batch_actions.clear_selection')}
{batchNarrow ? <X className="w-4 h-4" /> : t('batch_actions.clear_selection')}
</Button>
</div>
</div>
+57 -116
View File
@@ -123,7 +123,7 @@ import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenS
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { RulesPanel, useRulesAvailability } from "./rules-menu";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -1437,6 +1437,7 @@ export function EmailViewer({
);
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
@@ -1452,22 +1453,26 @@ export function EmailViewer({
// The panel takes its width from the message. When that would leave the
// message too narrow to read (three panes on a laptop), the panel starts
// collapsed; opening it anyway is still the user's call.
const viewerRootRef = useRef<HTMLDivElement>(null);
// A callback ref: the root only exists once a message is open (the empty
// and loading states render other markup), so the observer has to attach
// when the element appears, not when the plugin offer does.
const [viewerRootEl, setViewerRootEl] = useState<HTMLDivElement | null>(null);
const [viewerWidth, setViewerWidth] = useState(0);
const [detailSidebarForcedOpen, setDetailSidebarForcedOpen] = useState(false);
useEffect(() => {
if (!hasDetailSidebar || typeof ResizeObserver === 'undefined') return;
const el = viewerRootRef.current;
if (!el) return;
if (!hasDetailSidebar || !viewerRootEl || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width ?? 0);
setViewerWidth(width);
});
ro.observe(el);
ro.observe(viewerRootEl);
return () => ro.disconnect();
}, [hasDetailSidebar]);
}, [hasDetailSidebar, viewerRootEl]);
const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH;
const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen);
// Opened by hand while the message is too narrow for it: the panel lies over
// the message instead of squeezing it.
const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
@@ -4093,7 +4098,7 @@ export function EmailViewer({
return (
<div
ref={viewerRootRef}
ref={setViewerRootEl}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
>
@@ -4697,7 +4702,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
@@ -4735,7 +4740,7 @@ export function EmailViewer({
</span>
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
@@ -4754,23 +4759,11 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -4794,7 +4787,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -4811,7 +4804,7 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -4828,23 +4821,11 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5555,7 +5536,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
@@ -5598,7 +5579,7 @@ export function EmailViewer({
</span>
</div>
<div className={cn(
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
"absolute rounded-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
)}>
<button
@@ -5617,23 +5598,11 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5657,7 +5626,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -5674,7 +5643,7 @@ export function EmailViewer({
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -5691,23 +5660,11 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5729,7 +5686,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
@@ -5769,7 +5726,7 @@ export function EmailViewer({
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
@@ -5788,22 +5745,10 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5826,7 +5771,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -5844,7 +5789,7 @@ export function EmailViewer({
{formatFileSize(attachment.size)}
</span>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -5861,22 +5806,10 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5948,7 +5881,7 @@ export function EmailViewer({
)}
</div>
<PluginSlot name="email-footer" />
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
@@ -6225,18 +6158,26 @@ export function EmailViewer({
)}
{!sidebarCollapsed && (
<>
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
className="flex flex-col h-full border-s border-border bg-background overflow-hidden"
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
+40 -1
View File
@@ -16,6 +16,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { runAction } from "@/lib/plugin-sandbox/actions";
import { QUOTE_TOGGLE_ATTR } from "@/lib/quote-collapse";
import { cn } from "@/lib/utils";
import type { AttachmentInfo } from "@/lib/plugin-types";
// ─── attachment-menu ─────────────────────────────────────────
@@ -82,6 +83,41 @@ export function AttachmentPluginMenu({
);
}
// ─── attachment-actions ──────────────────────────────────────
/**
* The `attachment-actions` frames of one attachment, drawn next to its chip.
* They used to sit in the chip's hover overlay, where a wide frame was cut
* off and covered the name, and on phones (no hover) lay invisible over the
* chip and swallowed taps meant for its menu button.
*/
export function AttachmentPluginFrames({
emailId,
attachment,
className,
}: {
emailId: string | undefined;
attachment: AttachmentMenuTarget;
className?: string;
}) {
const extraProps = useMemo(() => ({
attachment: {
name: attachment.name || "",
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId,
} satisfies AttachmentInfo,
}), [emailId, attachment.name, attachment.type, attachment.size, attachment.blobId]);
return (
<PluginSlot
name="attachment-actions"
className={cn("max-w-full flex-shrink-0", className)}
extraProps={extraProps}
/>
);
}
// ─── Entries in a phone slide-in panel ───────────────────────
/**
@@ -145,7 +181,10 @@ export function insertQuoteAnchor(root: HTMLElement): HTMLElement | null {
if (!toggle?.parentNode) return null;
const anchor = root.ownerDocument.createElement("div");
anchor.setAttribute(QUOTE_ANCHOR_ATTR, "");
anchor.style.cssText = "display:block;margin:0;padding:0;border:0;height:0;white-space:normal;";
// No fixed height: in a plain-text body the frame renders inside the anchor
// and must push the quote toggle down, not lie over it. QuoteOverlay sets
// the height itself for an HTML body.
anchor.style.cssText = "display:block;margin:0;padding:0;border:0;white-space:normal;";
toggle.parentNode.insertBefore(anchor, toggle);
return anchor;
}
+38 -1
View File
@@ -43,6 +43,7 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
@@ -57,6 +58,8 @@ interface ThreadConversationViewProps {
onForward?: (email: Email) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
/** Role of the folder the conversation was opened from, for plugin actions limited to some folders. */
mailboxRole?: string | null;
}
// Helper function to get file icon based on mime type or extension
@@ -171,6 +174,7 @@ export function ThreadConversationView({
onForward,
onDownloadAttachment,
onMarkAsRead,
mailboxRole,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContent = useConversationExternalContent();
@@ -268,6 +272,7 @@ export function ThreadConversationView({
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
mailboxRole={mailboxRole}
/>
))}
</div>
@@ -290,6 +295,24 @@ interface EmailCardProps {
onForward?: () => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
mailboxRole?: string | null;
}
/**
* The single reader's plugin surfaces for one message of the phone
* conversation, mounted only while the message is unfolded: reader-header
* on top, reader-below-subject and email-banner under the sender,
* email-footer under the body. The props are the same as in the single
* reader and stay stable across re-renders.
*/
function useConversationPluginProps(email: Email) {
const headerOffers = usePluginSlotOffers('reader-header').length;
const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
const bannerOffers = usePluginSlotOffers('email-banner').length;
const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null }), [email, wantsView]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
return { emailProps, idProps };
}
function EmailCard({
@@ -305,9 +328,11 @@ function EmailCard({
onForward,
onDownloadAttachment,
onMarkAsRead,
mailboxRole,
}: EmailCardProps) {
const t = useTranslations();
const density = useSettingsStore((state) => state.density);
const plugin = useConversationPluginProps(email);
const sender = email.from?.[0];
const formatAddress = useOwnDomainAddress();
const isUnread = !email.keywords?.$seen;
@@ -322,6 +347,7 @@ function EmailCard({
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
{isExpanded && <PluginSlot name="reader-header" className="w-full" extraProps={plugin.emailProps} />}
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
@@ -375,6 +401,8 @@ function EmailCard({
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
<PluginSlot name="reader-below-subject" className="w-full" extraProps={plugin.emailProps} />
<PluginSlot name="email-banner" className="w-full" extraProps={plugin.emailProps} />
<ConversationEmailBody
email={email}
allowExternal={allowExternal}
@@ -382,8 +410,10 @@ function EmailCard({
onTrustSender={onTrustSender}
onDownloadAttachment={onDownloadAttachment}
/>
<PluginSlot name="email-footer" className="w-full" extraProps={plugin.idProps} />
{/* Action Buttons */}
{/* Action Buttons; plugin reader actions behind a "more" button, as
in the single reader's More panel on phones. */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
@@ -427,6 +457,13 @@ function EmailCard({
<span className="truncate">{t("email_viewer.forward")}</span>
</Button>
)}
<PluginActions
surface="reader-actions"
context={plugin.idProps}
mailboxRole={mailboxRole ?? null}
overflow
className="flex-shrink-0 rounded-md border border-border"
/>
</div>
</div>
)}
@@ -17,6 +17,11 @@ import type { FileResourceView } from '@/lib/plugin-types';
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,
@@ -97,6 +102,7 @@ function rowOf(name: string): HTMLElement {
const dispatch = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
device.isMobile = false;
localStorage.clear();
dispatch.mockClear();
setActionDispatcher(dispatch);
@@ -159,6 +165,26 @@ describe('file-actions', () => {
expect(context.files.map((f) => f.id)).toEqual(['f-notes', 'f-photo']);
});
it('puts them behind one more button in the phone selection toolbar', async () => {
device.isMobile = true;
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [
{ id: 'archive', label: 'Archive' },
{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] },
]));
renderBrowser(['notes.txt']);
const toolbar = screen.getByRole('toolbar', { name: 'selection_actions' });
expect(within(toolbar).queryByRole('button', { name: 'Archive' })).toBeNull();
const more = within(toolbar).getByRole('button', { name: 'more_actions' });
// Not in the row that scrolls sideways, where it could end up out of sight.
expect(more.closest('.overflow-x-auto')).toBeNull();
fireEvent.click(more);
expect(await screen.findByRole('menuitem', { name: 'Read text' })).toBeInTheDocument();
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Archive' })); });
const context = dispatch.mock.calls[0][3] as { files: FileResourceView[] };
expect(context.files.map((f) => f.id)).toEqual(['f-notes']);
});
it('filters the toolbar by the type of a single selected file', () => {
act(() => setPluginActions('ocr', 'OCR', 'file-actions', [{ id: 'read-text', label: 'Read text', mimeTypes: ['text/'] }]));
renderBrowser(['photo.png']);
+9 -4
View File
@@ -138,8 +138,10 @@ export function FilesHeader({
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
<SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
</div>
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
{compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
</div>
@@ -434,8 +436,11 @@ function SortItem({ label, checked, onClick, icon: Icon }: {
);
}
/** Plugin buttons in the selection toolbar, icon-only like the app's own. */
function SelectionPluginActions({ files, mimeType }: { files: FileResourceView[]; mimeType?: string }) {
/**
* Plugin buttons in the selection toolbar, icon-only like the app's own. On
* phones the toolbar has no room for them: they go behind one "more" button.
*/
function SelectionPluginActions({ files, mimeType, overflow }: { files: FileResourceView[]; mimeType?: string; overflow?: boolean }) {
const context = useMemo(() => ({ files }), [files]);
return <PluginActions surface="file-actions" context={context} mimeType={mimeType} compact className="flex-nowrap gap-0.5" />;
return <PluginActions surface="file-actions" context={context} mimeType={mimeType} compact overflow={overflow} className="flex-nowrap gap-0.5 flex-shrink-0" />;
}
+1
View File
@@ -164,6 +164,7 @@ export {
IconBeach as Palmtree,
IconBeach as PalmtreeIcon,
IconLayoutSidebarLeftCollapse as PanelLeftClose,
IconLayoutSidebarLeftExpand as PanelLeftOpen,
IconLayoutSidebarRight as PanelRight,
IconLayoutSidebarRightCollapse as PanelRightClose,
IconLayoutSidebarRightExpand as PanelRightOpen,
+1
View File
@@ -4857,6 +4857,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
await markAsRead(client, emailId, read);
}
}}
mailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
/>
) : (
<>
+44 -3
View File
@@ -1,8 +1,10 @@
'use client';
import { useSyncExternalStore, useState } from 'react';
import { iconForName, Puzzle } from '@/components/icons';
import { ContextMenuItem } from '@/components/ui/context-menu';
import { useTranslations } from 'next-intl';
import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { cn } from '@/lib/utils';
import {
actionApplies,
@@ -32,6 +34,8 @@ interface PluginActionsProps {
className?: string;
/** Icon-only buttons with the label as tooltip (narrow toolbars). */
compact?: boolean;
/** No room for buttons: one "more" button that opens the actions as a menu. */
overflow?: boolean;
/** Called after a click, e.g. to close the menu the entry sits in. */
onRan?: () => void;
}
@@ -40,10 +44,13 @@ interface PluginActionsProps {
* Buttons or menu entries plugins declared with api.ui.setActions. The host
* draws them so they match the app; the plugin gets the click.
*/
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) {
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan }: PluginActionsProps) {
const actions = usePluginActions(surface, { mailboxRole, mimeType });
const [busy, setBusy] = useState<string | null>(null);
if (actions.length === 0) return null;
if (overflow && variant === 'buttons') {
return <PluginActionsOverflow surface={surface} context={context} mailboxRole={mailboxRole} mimeType={mimeType} className={className} />;
}
const run = async (action: RegisteredAction) => {
const key = `${action.pluginId}:${action.id}`;
@@ -101,3 +108,37 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
</div>
);
}
/** The actions of a surface behind one "more" button, for toolbars without room for them. */
function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick<PluginActionsProps, 'surface' | 'context' | 'mailboxRole' | 'mimeType' | 'className'>) {
const t = useTranslations('email_viewer');
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<true>();
return (
<div className={cn('inline-flex items-center', className)} data-plugin-actions={surface} data-plugin-actions-overflow="">
<button
type="button"
title={t('more_actions')}
aria-label={t('more_actions')}
aria-haspopup="menu"
aria-expanded={contextMenu.isOpen}
onClick={(e) => { e.stopPropagation(); openContextMenu(e, true); }}
className={cn(
'inline-flex h-8 items-center rounded-md px-2 text-muted-foreground transition-colors',
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<MoreHorizontal className="h-4 w-4" />
</button>
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<PluginActions
surface={surface}
variant="menu"
context={context}
mailboxRole={mailboxRole}
mimeType={mimeType}
onRan={closeContextMenu}
/>
</ContextMenu>
</div>
);
}
@@ -94,6 +94,29 @@ describe('Settings plugin pages', () => {
expect(opts.plugin.id).toBe('kanban');
});
it('gives a plugin with both a page and a section one entry, with the section on its page', async () => {
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
const { container } = render(<SettingsApp linkSegments={[]} />);
const entries = screen.getAllByRole('button', { name: /Showcase/ });
expect(entries).toHaveLength(1);
act(() => { fireEvent.click(entries[0]); });
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2));
const slots = createSlotInstance.mock.calls.map((call) => (call[0] as { slot: string }).slot);
expect(slots.sort()).toEqual(['settings-page', 'settings-section']);
const section = container.querySelector('[data-plugin-slot="settings-section"]')!;
const page = container.querySelector('[data-plugin-slot="settings-page"]')!;
expect(section.compareDocumentPosition(page) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('opens the page of such a plugin from an old link to its section', async () => {
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
const { container } = render(<SettingsApp linkSegments={['plugin:showcase']} />);
await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull());
});
it('opens the page from a /settings/plugin-page:<id> link', async () => {
activate('kanban', 'Kanban', ['settings-page']);
render(<SettingsApp linkSegments={['plugin-page:kanban']} />);
+33 -10
View File
@@ -254,6 +254,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
() => pluginOffersForSlot('settings-page'),
);
const tPluginPages = useTranslations('plugin_pages');
const pluginPageIds = useMemo(() => new Set(pluginPageOffers.map((offer) => offer.pluginId)), [pluginPageOffers]);
const pluginSectionIds = useMemo(() => new Set(pluginSettingsOffers.map((offer) => offer.pluginId)), [pluginSettingsOffers]);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
@@ -584,7 +586,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Plugin-contributed settings pages: one entry per active plugin that
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
...pluginSettingsOffers.map((offer): TabDef => ({
// A plugin that also has a page of its own gets that entry only, with the
// section on its page, instead of two entries with the same name.
...pluginSettingsOffers.filter((offer) => !pluginPageIds.has(offer.pluginId)).map((offer): TabDef => ({
id: `plugin:${offer.pluginId}` as PluginTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
icon: Puzzle,
@@ -657,9 +661,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
// An old link or remembered tab to the section of a plugin that now has a
// page of its own opens that page, where the section is.
const sectionPluginId = activeTab.startsWith('plugin:') ? activeTab.slice('plugin:'.length) : null;
const requestedTab: SettingsTabId = sectionPluginId && pluginPageIds.has(sectionPluginId)
? (`${PLUGIN_PAGE_PREFIX}${sectionPluginId}` as PluginPageTabId)
: activeTab;
const isActiveVisible = visibleTabs.some((tab) => tab.id === requestedTab);
const effectiveActiveTab: SettingsTabId = isActiveVisible
? activeTab
? requestedTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: SettingsTabId) => {
@@ -736,13 +746,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
/>
)}
{effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) && (
<div className="w-full" data-plugin-slot="settings-page">
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
slot="settings-page"
/>
</div>
<>
{/* The plugin's settings-section, on its page rather than as a
second entry with the same name. */}
{pluginSectionIds.has(effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)) && (
<div className="max-w-3xl mb-6" data-plugin-slot="settings-section">
<PluginIframeSlot
key={`${effectiveActiveTab}:section`}
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
slot="settings-section"
/>
</div>
)}
<div className="w-full" data-plugin-slot="settings-page">
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
slot="settings-page"
/>
</div>
</>
)}
</>
);
@@ -0,0 +1,59 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ContextMenu, ContextMenuItem } from '../context-menu';
// A menu that grows after it opened (a plugin frame in it reports its
// height late) must be moved up again so it stays inside the window.
function rect(left: number, top: number, width: number, height: number): DOMRect {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
let resize: (() => void) | null = null;
class ManualResizeObserver {
constructor(cb: () => void) { resize = cb; }
observe() {}
unobserve() {}
disconnect() { resize = null; }
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
resize = null;
});
describe('ContextMenu fit', () => {
it('moves up when its content grows past the bottom of the window', () => {
vi.stubGlobal('ResizeObserver', ManualResizeObserver);
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
let height = 200;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? rect(0, 0, 220, height) : rect(0, 0, 0, 0);
});
render(
<ContextMenu isOpen position={{ x: 400, y: 500 }} onClose={() => {}}>
<ContextMenuItem label="Reply" onClick={() => {}} />
</ContextMenu>,
);
const menu = screen.getByRole('menu');
expect(menu.style.top).toBe('500px');
height = 624;
act(() => resize?.());
expect(menu.style.top).toBe(`${800 - 624 - 10}px`);
});
it('scrolls inside instead of running off a short window', () => {
render(
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
<ContextMenuItem label="Reply" onClick={() => {}} />
</ContextMenu>,
);
const inner = screen.getByRole('menu').firstElementChild as HTMLElement;
expect(inner.className).toContain('overflow-y-auto');
expect(inner.style.maxHeight).toMatch(/100vh/);
});
});
+26 -15
View File
@@ -35,6 +35,9 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
// `mounted` must be a dependency: on the very first open the menu isn't
// in the DOM yet (mounted is still false), so this effect has to re-run
// after the mount flip or the menu stays visibility:hidden.
//
// The menu can grow after it opened (a plugin frame in it reports its
// height later), so it is clamped again whenever its size changes.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
@@ -43,23 +46,30 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const node = localRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const clamp = () => {
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = position.x;
let y = position.y;
let x = position.x;
let y = position.y;
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition({ x, y });
setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
};
clamp();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(clamp);
observer.observe(node);
return () => observer.disconnect();
}, [isOpen, mounted, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
@@ -90,7 +100,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
role="menu"
aria-orientation="vertical"
>
<div className="py-1">
{/* Taller than the window: scroll inside the menu. */}
<div className="py-1 overflow-y-auto" style={{ maxHeight: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)` }}>
{children}
</div>
</div>,
+73
View File
@@ -317,6 +317,79 @@ describe('jmap.uploadBlob', () => {
expect(await gateError(plugin(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'x/y']))
.toMatch(/lacks permission "email:blob-write"/);
});
describe('checks what it uploads', () => {
const uploader = () => plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] });
const uploadBlob = vi.fn(async (file: File) => ({ blobId: 'b1', size: file.size, type: file.type, name: file.name }));
let maxSizeUpload = 0;
const session = () => useAuthStore.setState({ client: { uploadBlob, getMaxSizeUpload: () => maxSizeUpload } as never });
afterEach(() => {
uploadBlob.mockClear();
maxSizeUpload = 0;
useAuthStore.setState({ client: null });
});
it('refuses a missing or blank name, a bad media type and content that is not bytes', async () => {
session();
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), '', 'text/plain'])).toMatch(/name must be 1-255 characters/);
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), '\u0000\u0001', 'text/plain'])).toMatch(/name must be 1-255 characters/);
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'x'.repeat(256), 'text/plain'])).toMatch(/name must be 1-255 characters/);
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.txt', 'text/plain; charset=utf-8'])).toMatch(/type is not a media type/);
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.txt', 42])).toMatch(/type must be a string/);
expect(await gateError(uploader(), 'jmap.uploadBlob', ['hello', 'a.txt', 'text/plain'])).toMatch(/content must be a Uint8Array/);
expect(uploadBlob).not.toHaveBeenCalled();
});
it('caps the size at the server limit, or 50 MB when the server names none', async () => {
session();
maxSizeUpload = 10;
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(11), 'a.bin', 'application/octet-stream'])).toMatch(/more than the 10 bytes allowed/);
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(10), 'a.bin', 'application/octet-stream'])).toBe('');
maxSizeUpload = 0;
expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(50 * 1024 * 1024 + 1), 'a.bin', 'application/octet-stream']))
.toMatch(/more than the 52428800 bytes allowed/);
expect(uploadBlob).toHaveBeenCalledTimes(1);
});
it('uploads a clean file under its name and type', async () => {
session();
await dispatchApiCall(uploader(), 'jmap.uploadBlob', [new Uint8Array([1, 2, 3]), 'scan\u0007.pdf', 'application/pdf']);
const file = uploadBlob.mock.calls[0][0];
expect(file.name).toBe('scan.pdf');
expect(file.type).toBe('application/pdf');
expect(file.size).toBe(3);
});
});
});
describe('email.setKeyword and email.removeKeyword', () => {
const setKeyword = vi.fn(async () => {});
const removeKeyword = vi.fn(async () => {});
afterEach(() => {
setKeyword.mockClear();
removeKeyword.mockClear();
useAuthStore.setState({ client: null });
});
it('check the email id and account id like jmap.setKeyword does', async () => {
useAuthStore.setState({ client: { setKeyword, removeKeyword } as never });
const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] });
for (const method of ['email.setKeyword', 'email.removeKeyword']) {
expect(await gateError(writer, method, [undefined, '$label:work'])).toMatch(/emailId is required/);
expect(await gateError(writer, method, ['', '$label:work'])).toMatch(/emailId is required/);
expect(await gateError(writer, method, [{ id: 'e1' }, '$label:work'])).toMatch(/emailId is required/);
expect(await gateError(writer, method, ['e1', '$label:work', 7])).toMatch(/accountId must be a non-empty string/);
}
expect(setKeyword).not.toHaveBeenCalled();
expect(removeKeyword).not.toHaveBeenCalled();
await dispatchApiCall(writer, 'email.setKeyword', ['e1', '$label:work', 'acc-2']);
expect(setKeyword).toHaveBeenCalledWith('e1', '$label:work', 'acc-2');
await dispatchApiCall(writer, 'email.removeKeyword', ['e1', '$label:work']);
expect(removeKeyword).toHaveBeenCalledWith('e1', '$label:work', undefined);
});
});
describe('WebAuthn PRF salt', () => {
+17 -4
View File
@@ -127,6 +127,21 @@ export function nameOf(entry: string): string {
const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i;
/** A media type such as `application/pdf`, without parameters. */
export function readMediaType(value: unknown, method: string, field: string): string {
const type = readString(value, method, field, 255);
if (!MIME_TYPE_RE.test(type)) throw new Error(`${method}: ${field} is not a media type`);
return type;
}
/** A file name of 1-255 characters, control characters removed. */
export function readFileName(value: unknown, method: string, field: string): string {
// eslint-disable-next-line no-control-regex -- control characters in a file name are refused
const name = readString(value, method, field, 255, { min: 1 }).replace(/[\u0000-\u001f\u007f]/g, '');
if (!name.trim()) throw new Error(`${method}: ${field} must be 1-255 characters`);
return name;
}
export interface PluginBlobRef {
blobId: string;
name: string;
@@ -150,12 +165,10 @@ export function readBlobRefs(
return value.map((entry, index) => {
const field = `attachments[${index}]`;
const ref = readObject(entry, method, field, ['blobId', 'name', 'type', 'size']);
const type = readString(ref.type, method, `${field}.type`, 255);
if (!MIME_TYPE_RE.test(type)) throw new Error(`${method}: ${field}.type is not a media type`);
const type = readMediaType(ref.type, method, `${field}.type`);
return {
blobId: readId(ref.blobId, method, `${field}.blobId`),
// eslint-disable-next-line no-control-regex -- control characters in a file name are refused
name: readString(ref.name, method, `${field}.name`, 255, { min: 1 }).replace(/[\u0000-\u001f\u007f]/g, ''),
name: readFileName(ref.name, method, `${field}.name`),
type,
size: readInteger(ref.size, method, `${field}.size`, 0, maxSize),
};
+32 -8
View File
@@ -49,7 +49,7 @@ import {
import {
doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doMailboxesList,
} from './host-api-mail';
import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input';
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
import { doComposeOpen, doDraftsCreate } from './host-api-compose';
import { doNotificationsShow } from './host-api-notify';
@@ -699,10 +699,30 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st
return new Uint8Array(buf);
}
async function doJmapUploadBlob(content: Uint8Array, name: string, type: string): Promise<{ blobId: string; size: number; type: string; }> {
const { client } = useAuthStore.getState();
if (!client) throw new Error('jmap.uploadBlob: no active session');
const file = new File([content as BlobPart], name, { type });
/** Largest blob a plugin may upload when the server names no limit of its own. */
export const MAX_PLUGIN_UPLOAD_BYTES = 50 * 1024 * 1024;
/**
* jmap.uploadBlob: bytes from the plugin, checked like the attachment refs a
* plugin hands the composer (readBlobRefs): a real file name, a media type,
* and no more than the server accepts (its maxSizeUpload, or 50 MB when it
* names none).
*/
async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown): Promise<{ blobId: string; size: number; type: string; }> {
const method = 'jmap.uploadBlob';
const bytes = content instanceof Uint8Array ? content
: content instanceof ArrayBuffer ? new Uint8Array(content)
: null;
if (!bytes) throw new Error(`${method}: content must be a Uint8Array or ArrayBuffer`);
const fileName = readFileName(name, method, 'name');
const mediaType = readMediaType(type, method, 'type');
const client = sessionClient(method);
const serverLimit = client.getMaxSizeUpload();
const limit = serverLimit > 0 ? serverLimit : MAX_PLUGIN_UPLOAD_BYTES;
if (bytes.byteLength > limit) {
throw new Error(`${method}: content is ${bytes.byteLength} bytes, more than the ${limit} bytes allowed`);
}
const file = new File([bytes as BlobPart], fileName, { type: mediaType });
return await client.uploadBlob(file);
}
@@ -1720,7 +1740,7 @@ export async function dispatchApiCall(
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string, rangeHeader?: number } | undefined);
case 'jmap.uploadBlob': return doJmapUploadBlob(args[0] as Uint8Array, args[1] as string, args[2] as string);
case 'jmap.uploadBlob': return doJmapUploadBlob(args[0], args[1], args[2]);
case 'jmap.sendRaw': return doJmapSendRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
@@ -1891,13 +1911,17 @@ export async function dispatchApiCall(
}
case 'email.setKeyword': {
const emailId = assertEmailId(args[0], 'email.setKeyword');
const keyword = assertPluginKeyword(args[1]);
await requireClient().setKeyword(String(args[0]), keyword, args[2] as string | undefined);
const accountId = assertOptionalAccountId(args[2], 'email.setKeyword');
await requireClient().setKeyword(emailId, keyword, accountId);
return undefined;
}
case 'email.removeKeyword': {
const emailId = assertEmailId(args[0], 'email.removeKeyword');
const keyword = assertPluginKeyword(args[1]);
await requireClient().removeKeyword(String(args[0]), keyword, args[2] as string | undefined);
const accountId = assertOptionalAccountId(args[2], 'email.removeKeyword');
await requireClient().removeKeyword(emailId, keyword, accountId);
return undefined;
}
+4 -4
View File
@@ -68,7 +68,7 @@ export const SLOT_CATALOG = {
'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'a banner above the message body'),
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation'),
'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read' }, 'a side panel next to the open message'),
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
@@ -81,7 +81,7 @@ export const SLOT_CATALOG = {
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send' }, 'below the message text, above the attachments'),
'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'),
'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'),
'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open' }, 'the composer\'s attach menu, as a source of files', true),
'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open' }, 'the composer\'s plugin button row, next to composer-toolbar, as a source of files (for an entry in the Attach menu itself, use composer-attach-menu)', true),
'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send' }, 'entries in the menu next to Send'),
'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], {}, 'entries in the attach menu (for example "From Nextcloud")'),
@@ -89,7 +89,7 @@ export const SLOT_CATALOG = {
'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true),
'calendar-event-detail': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in an event\'s details'),
'calendar-event-form': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in the form for a new or edited event'),
'calendar-day-header': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day and week views (api.ui.setDayLabels)'),
'calendar-day-header': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day, week and month views; a dot in the phone month view (api.ui.setDayLabels)'),
'mini-calendar-marks': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'dots on days in the small month calendar (api.ui.setDayLabels)'),
// ─── Contacts ──────────────────────────────────────────────
@@ -106,7 +106,7 @@ export const SLOT_CATALOG = {
'file-preview-section': S('files', 'frame', ['ui:files'], { file: 'files:read' }, 'a section in the file preview'),
// ─── Settings and admin ────────────────────────────────────
'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings'),
'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings (above its settings-page, when it has one: one entry per plugin)'),
'settings-page': S('settings', 'frame', ['ui:settings-section'], {}, 'a page of its own in Settings, listed under the plugin\'s name'),
'admin-plugin-page': S('admin', 'frame', ['ui:admin-page', 'admin:config'], {}, 'the plugin\'s page in the admin dashboard'),
} as const satisfies Record<string, SlotSpec>;