diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx
index 7f518bb0c..3c6359d5f 100644
--- a/components/plugins/plugin-actions.tsx
+++ b/components/plugins/plugin-actions.tsx
@@ -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(null);
if (actions.length === 0) return null;
+ if (overflow && variant === 'buttons') {
+ return ;
+ }
const run = async (action: RegisteredAction) => {
const key = `${action.pluginId}:${action.id}`;
@@ -101,3 +108,37 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
);
}
+
+/** The actions of a surface behind one "more" button, for toolbars without room for them. */
+function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, className }: Pick) {
+ const t = useTranslations('email_viewer');
+ const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu();
+ return (
+
+
+
+
+
+
+ );
+}
From dc1a19ad8b9ebccbfab86f376c8d3c29978f564c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:34:11 +0200
Subject: [PATCH 06/14] fix: give composer plugin panels a fixed width and a
toggle, and fold them when the composer gets too narrow
---
.../composer-plugin-surfaces.test.tsx | 65 ++++++
components/email/composer-plugin-sidebars.tsx | 186 ++++++++++++++++++
components/email/email-composer.tsx | 17 +-
components/icons/index.ts | 1 +
4 files changed, 260 insertions(+), 9 deletions(-)
create mode 100644 components/email/composer-plugin-sidebars.tsx
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 9f1ca493c..30513f9b0 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -344,3 +344,68 @@ 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(`[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();
+
+ 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();
+ expect(panel('left').dataset.composerSidebarMode).toBe('docked');
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
+ unmount();
+
+ stubWidth(600);
+ render();
+ 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();
+
+ 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');
+ });
+});
diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx
new file mode 100644
index 000000000..21d0c0c79
--- /dev/null
+++ b/components/email/composer-plugin-sidebars.tsx
@@ -0,0 +1,186 @@
+"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. */
+export const MIN_COMPOSER_WIDTH = 480;
+
+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 {
+ 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>(() => ({
+ left: readCollapsed("left"),
+ right: readCollapsed("right"),
+ }));
+ const [overlayOpen, setOverlayOpen] = useState>({ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {side === "left"
+ ?
+ : }
+
+
+ );
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index cbd2c9cff..beaae874d 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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(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 (
-
+ pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
)}
);
}
diff --git a/components/icons/index.ts b/components/icons/index.ts
index 8c3b910ef..d71b4b106 100644
--- a/components/icons/index.ts
+++ b/components/icons/index.ts
@@ -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,
From b519b6aa449cddcfd0b63c53512dee12a5069891 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:37:59 +0200
Subject: [PATCH 07/14] docs: say that composer-attachment-source sits in the
composer's plugin button row, not the Attach menu
---
.../email/__tests__/composer-plugin-surfaces.test.tsx | 10 ++++++++++
lib/plugin-slot-catalog.ts | 2 +-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 30513f9b0..5ba9dc45b 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -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();
+
+ 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();
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 03d1e1213..c633f629b 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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")'),
From 173319120ad42390c5bc7f55ba2becb74ee2c3a9 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:39:55 +0200
Subject: [PATCH 08/14] fix: keep the event popover inside the window when a
plugin section makes it taller
---
.../calendar-plugin-surfaces.test.tsx | 41 +++++++++++++++++++
components/calendar/event-detail-popover.tsx | 26 ++++++++++--
2 files changed, 63 insertions(+), 4 deletions(-)
diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
index 06e07cf7d..9c44317d9 100644
--- a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -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(
+ ,
+ );
+ 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();
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index 7a54d8f66..4ab0eba77 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -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 */}
{/* Date & Time */}
From e1c4a721a33db7c0b73ed744ce5b2a2850735b3d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:42:48 +0200
Subject: [PATCH 09/14] fix: show plugin day labels as dots in the phone month
view and keep the mini calendar dot visible on today
---
.../calendar-plugin-surfaces.test.tsx | 33 +++++++++++++++++--
components/calendar/calendar-month-view.tsx | 3 +-
components/calendar/plugin-day-label.tsx | 26 +++++++++++++--
lib/plugin-slot-catalog.ts | 2 +-
4 files changed, 57 insertions(+), 7 deletions(-)
diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
index 9c44317d9..b1d0063c1 100644
--- a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -194,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();
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();
- expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ const dots = [...document.querySelectorAll('[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', () => {
@@ -224,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(
+ ,
+ );
+ const dot = document.querySelector('[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();
+ }
+ });
});
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx
index 1164876b5..cd413898a 100644
--- a/components/calendar/calendar-month-view.tsx
+++ b/components/calendar/calendar-month-view.tsx
@@ -380,7 +380,8 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
- {!isMobile && }
+ {/* Phones have no room for the text: a dot stands in for it. */}
+
{isMobile && !showChips ? (
diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx
index 63ff9aa0f..5fc22e768 100644
--- a/components/calendar/plugin-day-label.tsx
+++ b/components/calendar/plugin-day-label.tsx
@@ -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)}
/>
{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}
>
);
}
+ if (compact) {
+ const shown = labels.filter((l) => l.text || l.dot);
+ if (shown.length === 0) return null;
+ return (
+
+
+ {shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}
+
+ );
+ }
+
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index c633f629b..768e2bb02 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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 ──────────────────────────────────────────────
From 2b493f8f9c2d530605907b04d31061b67e3218aa Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:46:03 +0200
Subject: [PATCH 10/14] feat: show the reader's plugin frames and actions on
the open message of a phone conversation
---
.../__tests__/reader-plugin-surfaces.test.tsx | 42 +++++++++++++++++++
components/email/thread-conversation-view.tsx | 39 ++++++++++++++++-
components/mail/mail-app.tsx | 1 +
lib/plugin-slot-catalog.ts | 2 +-
4 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx
index 679b5aee8..f4241ca62 100644
--- a/components/email/__tests__/reader-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx
@@ -263,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),
+ textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial),
+ ];
+ 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( {}} />);
+
+ // 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( {}} 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( {}} 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' }]));
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index ea585a6f7..3acf3b128 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -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}
/>
))}
@@ -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 && }
{/* Card Header - Always visible */}
)}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index b1f993a90..759afa034 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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)}
/>
) : (
<>
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 768e2bb02..f6bece96c 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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'),
From d16edfe4026712da29e528229ddeef714c1808cf Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:48:50 +0200
Subject: [PATCH 11/14] fix: put plugin file actions behind a more button in
the phone selection toolbar
---
.../__tests__/files-plugin-surfaces.test.tsx | 26 +++++++++++++++++++
components/files/files-header.tsx | 13 +++++++---
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/components/files/__tests__/files-plugin-surfaces.test.tsx b/components/files/__tests__/files-plugin-surfaces.test.tsx
index c2bcee520..93359c09d 100644
--- a/components/files/__tests__/files-plugin-surfaces.test.tsx
+++ b/components/files/__tests__/files-plugin-surfaces.test.tsx
@@ -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()),
+ 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']);
diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx
index b39ccfd94..9fdb298cc 100644
--- a/components/files/files-header.tsx
+++ b/components/files/files-header.tsx
@@ -138,8 +138,10 @@ export function FilesHeader({
-
+ {!compact && }
+ {/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
+ {compact && }
@@ -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 ;
+ return ;
}
From b88232c4e82147ac1bd07db84fb21fc10d866e67 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:52:52 +0200
Subject: [PATCH 12/14] fix: list a plugin with both a settings page and a
settings section once, with the section on its page
---
.../__tests__/settings-plugin-page.test.tsx | 23 ++++++++++
components/settings/settings-app.tsx | 43 ++++++++++++++-----
lib/plugin-slot-catalog.ts | 2 +-
3 files changed, 57 insertions(+), 11 deletions(-)
diff --git a/components/settings/__tests__/settings-plugin-page.test.tsx b/components/settings/__tests__/settings-plugin-page.test.tsx
index 40887a75e..622628fe3 100644
--- a/components/settings/__tests__/settings-plugin-page.test.tsx
+++ b/components/settings/__tests__/settings-plugin-page.test.tsx
@@ -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();
+
+ 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();
+
+ await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull());
+ });
+
it('opens the page from a /settings/plugin-page: link', async () => {
activate('kanban', 'Kanban', ['settings-page']);
render();
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index 42c26226e..be232a5e9 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -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) && (
-
-
-
+ <>
+ {/* 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)) && (
+
+
+
+ )}
+
+
+
+ >
)}
>
);
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index f6bece96c..f6e307bdf 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -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;
From 7f7f6bf94978ccd4193f440b510dd8f8b6677b5d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 10:00:03 +0200
Subject: [PATCH 13/14] fix: check the name, type and size of blobs plugins
upload, and the email id of email.setKeyword and email.removeKeyword
---
lib/__tests__/plugin-api-gates.test.ts | 73 ++++++++++++++++++++++++++
lib/plugin-sandbox/host-api-input.ts | 21 ++++++--
lib/plugin-sandbox/host-api.ts | 40 +++++++++++---
3 files changed, 122 insertions(+), 12 deletions(-)
diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts
index d33b80ad1..7e5205a4d 100644
--- a/lib/__tests__/plugin-api-gates.test.ts
+++ b/lib/__tests__/plugin-api-gates.test.ts
@@ -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', () => {
diff --git a/lib/plugin-sandbox/host-api-input.ts b/lib/plugin-sandbox/host-api-input.ts
index d76dba55a..eb94c7c35 100644
--- a/lib/plugin-sandbox/host-api-input.ts
+++ b/lib/plugin-sandbox/host-api-input.ts
@@ -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),
};
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 5752b7600..c1767e787 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -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;
}
From 5b8ccb2f062cde8d57962c53af156d1efad6211e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 10:05:17 +0200
Subject: [PATCH 14/14] fix: fold composer plugin panels before the composer's
bottom bar runs into itself
---
.../email/__tests__/composer-plugin-surfaces.test.tsx | 4 +++-
components/email/composer-plugin-sidebars.tsx | 7 +++++--
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 5ba9dc45b..eda652956 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -400,7 +400,9 @@ describe('composer-sidebar and composer-sidebar-right', () => {
expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
unmount();
- stubWidth(600);
+ // The reading pane of a 1280 px window: an open panel would leave the
+ // composer too narrow for its bottom bar.
+ stubWidth(792);
render();
expect(panel('left').dataset.composerSidebarMode).toBe('folded');
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx
index 21d0c0c79..c2fa7860a 100644
--- a/components/email/composer-plugin-sidebars.tsx
+++ b/components/email/composer-plugin-sidebars.tsx
@@ -17,8 +17,11 @@ import { cn } from "@/lib/utils";
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. */
-export const MIN_COMPOSER_WIDTH = 480;
+/**
+ * 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. */