From 97e6256c0e6db8a347757bb97fd745247cc7d5ce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:51:20 +0100 Subject: [PATCH 001/174] fix: show the inbox in the demo when a category tab filters on a header --- lib/__tests__/demo-client-filter.test.ts | 45 ++++++++++++++++++++++++ lib/demo/demo-client.ts | 15 ++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/demo-client-filter.test.ts diff --git a/lib/__tests__/demo-client-filter.test.ts b/lib/__tests__/demo-client-filter.test.ts new file mode 100644 index 000000000..33003e4a2 --- /dev/null +++ b/lib/__tests__/demo-client-filter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { DemoJMAPClient } from '@/lib/demo/demo-client'; + +// A `header` condition used to be ignored, so it matched every message. The +// category tabs' Promotions rule has one, which left the default tab (NOT all +// the other tabs) empty on an Inbox full of mail. +describe('DemoJMAPClient header filter', () => { + async function inboxOf(client: DemoJMAPClient) { + return (await client.getMailboxes()).find((m) => m.role === 'inbox')!; + } + + it('matches only messages that carry the header', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const all = await client.getEmails(inbox.id, undefined, 500); + const listed = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['List-Id'] }); + + expect(listed.total).toBeGreaterThan(0); + expect(listed.total).toBeLessThan(all.total); + for (const e of listed.emails) { + expect(Object.keys(e.headers ?? {}).map((h) => h.toLowerCase())).toContain('list-id'); + } + }); + + it('compares the value when the condition has one', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const hit = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['list-id', 'BEACON.meridianlabs'] }); + const miss = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { header: ['List-Id', 'no-such-list'] }); + + expect(hit.total).toBeGreaterThan(0); + expect(miss.total).toBe(0); + }); + + it('leaves mail in a default tab that excludes a header rule', async () => { + const client = new DemoJMAPClient(); + const inbox = await inboxOf(client); + const primary = await client.getEmails(inbox.id, undefined, 500, 0, undefined, false, { + operator: 'NOT', + conditions: [{ operator: 'OR', conditions: [{ from: 'mailchimp' }, { header: ['List-Unsubscribe'] }] }], + }); + + expect(primary.total).toBeGreaterThan(0); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 8a9377ec4..b1eae913d 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -523,8 +523,9 @@ export class DemoJMAPClient implements IJMAPClient { /** * Minimal JMAP filter evaluator for demo mode: supports the conditions the * message-list category tabs and the folder-less search scopes use - * (hasKeyword / notKeyword / from / text / inMailbox / inMailboxOtherThan - * and AND / OR / NOT operators). Unknown conditions are ignored. + * (hasKeyword / notKeyword / from / text / header / inMailbox / + * inMailboxOtherThan and AND / OR / NOT operators). Unknown conditions are + * ignored. */ private matchesFilter(e: Email, filter: Record): boolean { if (typeof filter.operator === 'string') { @@ -564,6 +565,16 @@ export class DemoJMAPClient implements IJMAPClient { const bodyText = Object.values(e.bodyValues || {}).map(v => v.value).join(' ').toLowerCase(); if (!bodyText.includes(q)) return false; } + // RFC 8621 4.4.1: [name] tests that the header exists, [name, value] that + // its value contains the text. Ignoring it would match every message. + if (Array.isArray(filter.header) && typeof filter.header[0] === 'string') { + const wanted = filter.header[0].toLowerCase(); + const text = typeof filter.header[1] === 'string' ? filter.header[1].toLowerCase() : null; + const match = Object.entries(e.headers ?? {}).some(([name, value]) => + name.toLowerCase() === wanted + && (text === null || (Array.isArray(value) ? value : [value]).some(v => String(v).toLowerCase().includes(text)))); + if (!match) return false; + } if (typeof filter.inMailbox === 'string' && !e.mailboxIds[filter.inMailbox]) return false; if (Array.isArray(filter.inMailboxOtherThan)) { const excluded = new Set(filter.inMailboxOtherThan as string[]); From 0fd77edeca07494d1377eb397c60c3e019a60ed5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:01:08 +0100 Subject: [PATCH 002/174] fix: keep the composer's Close, panel button and Send reachable while a plugin panel lies over it --- .../composer-plugin-surfaces.test.tsx | 23 +++++++++++ components/email/composer-plugin-sidebars.tsx | 40 ++++++++++++++++--- components/email/email-composer.tsx | 1 + 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index c9812f7c9..787389c18 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -543,6 +543,29 @@ describe('composer-sidebar and composer-sidebar-right', () => { expect(panel('right').style.width).toBe('360px'); }); + it('keep a panel laid over the composer between its header and its bottom bar', () => { + stubWidth(700); + offerSlots('p1', ['composer-sidebar-right']); + const rect = (top: number, bottom: number) => ({ top, bottom, left: 0, right: 700, width: 700, height: bottom - top, x: 0, y: top, toJSON: () => ({}) }); + const original = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (this: Element) { + if (this.matches('[data-testid="email-composer"]')) return rect(100, 800); + if (this.matches('[data-composer-header]')) return rect(100, 156); + if (this.matches('[data-composer-footer]')) return rect(740, 800); + return original.call(this); + }; + try { + localStorage.setItem('composerPluginSidebarCollapsed:right', '1'); + render(); + fireEvent.click(document.querySelector('[data-composer-panel-toggle="right"]')!); + expect(panel('right').dataset.composerSidebarMode).toBe('overlay'); + expect(panel('right').style.top).toBe('56px'); + expect(panel('right').style.bottom).toBe('60px'); + } finally { + Element.prototype.getBoundingClientRect = original; + } + }); + it('folds a widened panel sooner, since it needs the room', () => { localStorage.setItem('composerPluginSidebarWidth:right', '480'); stubWidth(1000); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index a0a2bf1b4..6fcae80da 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -7,9 +7,10 @@ // room at all. 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. +// can write in. Laid over, it stays between the composer's header and its +// bottom bar: the panel's own button, Close and Send stay in reach. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { useTranslations } from "next-intl"; import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons"; import { PluginSlot } from "@/components/plugins/plugin-slot"; @@ -229,6 +230,32 @@ export function ComposerPanelToggle({ ); } +const COMPOSER_HEADER = "[data-composer-header], [data-mv-compose-titlebar], [data-mv-compose-inline-bar]"; +const COMPOSER_FOOTER = "[data-composer-footer]"; + +/** How far a panel laid over the composer keeps off its top and bottom, to leave the header and the bottom bar free. */ +function useOverlayInsets(active: boolean, panelRef: RefObject): { top: number; bottom: number } { + const [insets, setInsets] = useState({ top: 0, bottom: 0 }); + useLayoutEffect(() => { + const root = panelRef.current?.parentElement; + if (!active || !root) return; + const header = root.querySelector(COMPOSER_HEADER); + const footer = root.querySelector(COMPOSER_FOOTER); + const measure = () => { + const box = root.getBoundingClientRect(); + const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom - box.top)) : 0; + const bottom = footer ? Math.max(0, Math.round(box.bottom - footer.getBoundingClientRect().top)) : 0; + setInsets((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom })); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + for (const el of [root, header, footer]) if (el) observer.observe(el); + return () => observer.disconnect(); + }, [active, panelRef]); + return insets; +} + /** One plugin panel of the composer, in the mode useComposerSidebars picked. */ export function ComposerPluginSidebar({ side, @@ -246,6 +273,8 @@ export function ComposerPluginSidebar({ onWidthChange?: (width: number) => void; }) { const dragStart = useRef(width); + const panelRef = useRef(null); + const insets = useOverlayInsets(mode === "overlay", panelRef); if (!mode) return null; const border = side === "left" ? "border-e" : "border-s"; @@ -274,12 +303,13 @@ export function ComposerPluginSidebar({ <> {side === "right" && handle}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 77163d04b..194b731c2 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5052,6 +5052,7 @@ export function EmailComposer({ one row (see [data-mv-compose-footer] in globals.css): Send first, the tools after it, and Discard as a bin at the far end. */}
Date: Tue, 3 Nov 2026 05:02:05 +0100 Subject: [PATCH 003/174] fix: move plugin toolbar buttons into the more menu when the reader toolbar is out of room --- .../__tests__/reader-plugin-surfaces.test.tsx | 26 ++++++++++++++ components/email/email-context-menu.tsx | 4 +++ components/email/email-viewer.tsx | 34 +++++++++++++++++-- components/plugins/plugin-actions.tsx | 25 +++++++++++--- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx index 9488dd765..27692ac4f 100644 --- a/components/email/__tests__/reader-plugin-surfaces.test.tsx +++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx @@ -442,6 +442,32 @@ describe('reader-actions', () => { expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull(); }); + it('joins the toolbar overflow, giving way before the items of the toolbar', () => { + act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'one', label: 'One' }, { id: 'two', label: 'Two' }])); + viewer(textMail('Hola')); + + const priority = (name: string) => Number(screen.getByRole('button', { name }).dataset.overflowPriority); + expect(priority('One')).toBeGreaterThan(11); + expect(priority('Two')).toBeGreaterThan(priority('One')); + }); + + it('offers the buttons with no room on the toolbar in the more menu, and only those', async () => { + act(() => setPluginActions('p', 'Translator', 'reader-actions', [ + { id: 'fits', label: 'Fits' }, + { id: 'translate', label: 'Translate' }, + ])); + viewer(textMail('Hola')); + // jsdom lays nothing out, so a button without rects reads as hidden. + vi.spyOn(screen.getByRole('button', { name: 'Fits' }), 'getClientRects') + .mockReturnValue([new DOMRect(0, 0, 40, 32)] as unknown as DOMRectList); + + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + const item = await screen.findByRole('menuitem', { name: 'Translate' }); + expect(screen.queryByRole('menuitem', { name: 'Fits' })).toBeNull(); + await act(async () => { fireEvent.click(item); }); + expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' }); + }); + it('puts them in the More panel on phones', async () => { device.isMobile = true; act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }])); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 5b4684da0..6e7b849fa 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -133,6 +133,8 @@ interface EmailContextMenuProps { extras?: MessageActionEntry[]; /** The row's plugin entries (message-row-actions). Default true. */ rowPlugins?: boolean; + /** Entries a caller adds after the message's own, such as its toolbar's overflow. */ + extraEntries?: React.ReactNode; /** The menu's accessible name, e.g. "More actions" from the reader's ⋮. */ label?: string; } @@ -189,6 +191,7 @@ export function EmailContextMenu({ omit, extras, rowPlugins = true, + extraEntries, label, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); @@ -567,6 +570,7 @@ export function EmailContextMenu({ <> {singleMenu} {rowPlugins && rowPluginBlock} + {extraEntries} )} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9162f0d14..8ef58f27e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -30,7 +30,7 @@ import { buildContactsPath } from "@/lib/deep-links"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { Avatar } from "@/components/ui/avatar"; -import { ContextMenu } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu"; import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu"; import { EmailContextMenu } from "./email-context-menu"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions"; @@ -208,6 +208,7 @@ const TOOLBAR_PRIORITY_ACTIONS: Record = { '11': 'extra:view_mode', }; const NO_ACTION_KEYS: ReadonlySet = new Set(); +const NO_PLUGIN_KEYS: ReadonlySet = new Set(); /** Whatever a sub-view offers to act on, in the order it is read out. */ const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]'; @@ -1090,10 +1091,12 @@ interface ReaderMoreMenuProps { selectedMailbox: string; onMoveToMailbox?: (mailboxId: string) => void; onSetTag?: (tagId: string | null) => void; + /** The plugin buttons that gave way on the toolbar, as entries of the menu. */ + pluginEntries?: React.ReactNode; } /** The reader's ⋮ on a desktop: the row's context menu, anchored to the button (R6). */ -function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMoveToMailbox, onSetTag }: ReaderMoreMenuProps) { +function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMoveToMailbox, onSetTag, pluginEntries }: ReaderMoreMenuProps) { const { email } = options; const { copyTargets, copyToAccount } = useCopyToAccount(email, true); return ( @@ -1130,6 +1133,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo omit={options.omit} extras={options.extras} rowPlugins={false} + extraEntries={pluginEntries} /> ); } @@ -1684,6 +1688,8 @@ export function EmailViewer({ const tagMenuOpen = tagMenu.contextMenu.isOpen; // What the toolbar shows when the ⋮ opens; the menu leaves those out. const [readerMenuOmit, setReaderMenuOmit] = useState>(NO_ACTION_KEYS); + // The plugin buttons that had no room on the toolbar when the ⋮ opened. + const [readerMenuPluginKeys, setReaderMenuPluginKeys] = useState>(NO_PLUGIN_KEYS); const moreMenuRef = useRef(null); const moreButtonRef = useRef(null); // The rows that open a sub-view, and the pieces of the mobile panel a @@ -4112,6 +4118,14 @@ ${printWatermark ? printWatermark.html : ''} } return shown; }; + // The plugin buttons that gave way on the toolbar: the ⋮ menu offers them. + const toolbarHiddenPluginActions = (): ReadonlySet => { + const hidden = new Set(); + toolbarRef.current?.querySelectorAll('[data-plugin-action-key]').forEach((item) => { + if (item.getClientRects().length === 0) hidden.add(item.dataset.pluginActionKey!); + }); + return hidden; + }; const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); // The reader's own entries after the message's: the view toggle, Import // (into the open folder) and, with a keyboard, the shortcuts. @@ -4297,6 +4311,7 @@ ${printWatermark ? printWatermark.html : ''} target={readerActionTarget} mailboxRole={currentMailboxRole ?? null} compact={!showToolbarLabels} + overflowItems /> )}
@@ -4524,6 +4539,7 @@ ${printWatermark ? printWatermark.html : ''} return; } setReaderMenuOmit(toolbarShownActions()); + setReaderMenuPluginKeys(toolbarHiddenPluginActions()); readerMenu.openContextMenuFrom(e.currentTarget, null, 'end'); }} > @@ -4539,6 +4555,20 @@ ${printWatermark ? printWatermark.html : ''} selectedMailbox={selectedMailbox} onMoveToMailbox={onMoveToMailbox} onSetTag={onSetTag ? (tagId) => onSetTag(email.id, tagId) : undefined} + pluginEntries={readerMenuPluginKeys.size > 0 ? ( + <> + + + + ) : undefined} /> )}
diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx index 744c18823..217f50fe1 100644 --- a/components/plugins/plugin-actions.tsx +++ b/components/plugins/plugin-actions.tsx @@ -113,14 +113,26 @@ interface PluginActionsProps { target?: MessageActionTarget; /** Surfaces about the selection: the open folder's account kind. */ accountKind?: MailAccountKind | null; + /** + * The buttons join their toolbar's overflow measurement, on one line, and + * give way before the toolbar's own items. Buttons that gave way are named + * by `data-plugin-action-key`, for `only` on the toolbar's menu. + */ + overflowItems?: boolean; + /** Draw only these actions, by `data-plugin-action-key`. */ + only?: ReadonlySet; } +/** Above every item of a toolbar's own, so that a plugin's button gives way first. */ +const PLUGIN_OVERFLOW_PRIORITY_BASE = 100; + /** * 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, overflow, onRan, target, accountKind }: PluginActionsProps) { - const actions = usePluginActions(surface, { mailboxRole, mimeType, ...targetFilter(target, accountKind) }); +export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, overflow, onRan, target, accountKind, overflowItems, only }: PluginActionsProps) { + const applicable = usePluginActions(surface, { mailboxRole, mimeType, ...targetFilter(target, accountKind) }); + const actions = only ? applicable.filter((a) => only.has(`${a.pluginId}:${a.id}`)) : applicable; const [busy, setBusy] = useState(null); if (actions.length === 0) return null; if (overflow && variant === 'buttons') { @@ -157,8 +169,8 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo } return ( -
- {actions.map((action) => { +
+ {actions.map((action, index) => { const Icon = iconForName(action.icon) ?? Puzzle; const key = `${action.pluginId}:${action.id}`; return ( @@ -168,6 +180,11 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo disabled={busy === key} title={compact ? `${action.label} (${action.pluginName})` : action.pluginName} aria-label={action.label} + {...(overflowItems ? { + 'data-overflow-item': '', + 'data-overflow-priority': PLUGIN_OVERFLOW_PRIORITY_BASE + index, + 'data-plugin-action-key': key, + } : {})} onClick={(e) => { e.stopPropagation(); void run(action); }} className={cn( 'relative inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors', From f5ed731b51afd6fcead9ab9c90eed6e254452c7d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:02:35 +0100 Subject: [PATCH 004/174] fix: keep settings selects and option buttons inside the page on small phones --- components/settings/settings-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index fb7aa6891..3b2511989 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -280,7 +280,7 @@ export function SettingItem({ label, description, children, locked, advanced, ne
{/* The admin's value stays readable; inert keeps mouse and keyboard off it. */} -
{children}
+
{children}
); @@ -338,7 +338,7 @@ interface RadioGroupProps { export function RadioGroup({ value, onChange, options }: RadioGroupProps) { const labelledBy = useContext(SettingLabelContext); return ( -
+
{options.map((option) => (