From 3d16450e5dcf903022088650fbac1adfbd95637c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:11:45 +0200 Subject: [PATCH 01/14] fix: collapse the plugin side panel when the message pane is too narrow, and lay it over the message when opened anyway --- .../__tests__/reader-plugin-surfaces.test.tsx | 54 +++++++++++++++++++ components/email/email-viewer.tsx | 48 ++++++++++------- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 588f2ee90..a5586710b 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -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( + , + ); + rerender(); + + 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']); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 150257185..0fb8dfe83 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1452,22 +1452,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(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(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 +4097,7 @@ export function EmailViewer({ return (
@@ -6225,18 +6229,26 @@ export function EmailViewer({ )} {!sidebarCollapsed && ( <> - { 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 && ( + { 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)} + /> + )}
- + {/* Mountain View: the conversation's later messages, folded. */} {mvAfter.length > 0 && ( diff --git a/components/email/reader-plugin-surfaces.tsx b/components/email/reader-plugin-surfaces.tsx index dbba3688d..7a7d87f8b 100644 --- a/components/email/reader-plugin-surfaces.tsx +++ b/components/email/reader-plugin-surfaces.tsx @@ -145,7 +145,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; } From d6c9916f5db174f533d1f08cba8cea0f34c645f2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:23:22 +0200 Subject: [PATCH 03/14] fix: show plugin attachment buttons next to the attachment instead of in its hover overlay, so they no longer cover the name or block taps on phones --- .../__tests__/reader-plugin-surfaces.test.tsx | 34 +++++ components/email/email-viewer.tsx | 122 ++++-------------- components/email/reader-plugin-surfaces.tsx | 36 ++++++ 3 files changed, 95 insertions(+), 97 deletions(-) diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 301257065..679b5aee8 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -297,6 +297,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); + + 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('[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, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index a43e1c776..650a86e6e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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"; @@ -4702,7 +4702,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )} ); })} @@ -4799,7 +4787,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
{formatFileSize(attachment.size)} -
+
)} -
- )} + + )} ); })} @@ -5560,7 +5536,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )}
); })} @@ -5662,7 +5626,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
{formatFileSize(attachment.size)} -
+
)} -
- )} + + )} ); })} @@ -5734,7 +5686,7 @@ export function EmailViewer({ const thumbUrl = imageThumbUrls[attachment.id]; return ( - {(dragProps) => ( + {(dragProps) => (<>
)} -
- )} + + )}
); })} @@ -5831,7 +5771,7 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( - {(dragProps) => ( + {(dragProps) => (<>
-
+
)} -
- )} + + )} ); })} diff --git a/components/email/reader-plugin-surfaces.tsx b/components/email/reader-plugin-surfaces.tsx index 7a7d87f8b..f6f3cde0f 100644 --- a/components/email/reader-plugin-surfaces.tsx +++ b/components/email/reader-plugin-surfaces.tsx @@ -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 ( + + ); +} + // ─── Entries in a phone slide-in panel ─────────────────────── /** From 937025be8dff79d6a68848c65112f8f53f43805b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:25:26 +0200 Subject: [PATCH 04/14] fix: keep context menus inside the window when a plugin frame in them grows after opening --- .../ui/__tests__/context-menu-fit.test.tsx | 59 +++++++++++++++++++ components/ui/context-menu.tsx | 41 ++++++++----- 2 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 components/ui/__tests__/context-menu-fit.test.tsx diff --git a/components/ui/__tests__/context-menu-fit.test.tsx b/components/ui/__tests__/context-menu-fit.test.tsx new file mode 100644 index 000000000..c9c95da28 --- /dev/null +++ b/components/ui/__tests__/context-menu-fit.test.tsx @@ -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( + {}}> + {}} /> + , + ); + 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( + {}}> + {}} /> + , + ); + const inner = screen.getByRole('menu').firstElementChild as HTMLElement; + expect(inner.className).toContain('overflow-y-auto'); + expect(inner.style.maxHeight).toMatch(/100vh/); + }); +}); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index dbe9b0b25..a8dc532a8 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -35,6 +35,9 @@ export const ContextMenu = forwardRef( // `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( 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( role="menu" aria-orientation="vertical" > -
+ {/* Taller than the window: scroll inside the menu. */} +
{children}
, From c87ef9c44a89a21201db239ae3d82e4d1142661b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:28:56 +0200 Subject: [PATCH 05/14] fix: move plugin buttons in a narrow selection toolbar into a more menu so the toolbar no longer wraps --- .../mail-list-plugin-surfaces.test.tsx | 35 ++++++++++++++ components/email/email-list.tsx | 36 ++++++++++---- components/plugins/plugin-actions.tsx | 47 +++++++++++++++++-- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx index e228eb913..cba16b2d7 100644 --- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -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(); + + 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']) }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 3c974a155..a52fe0b68 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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" )} > -
-
- +
+
+ {t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
-
+
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. */}
)}
- + pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
); } 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 ( + + + ); + } + 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. */