mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
Merge branch 'fix/plugin-surfaces' into feat/plugin-system-audit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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']} />);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user