From 976f3ea543e158f28c4030a855aad9bd55317aba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:12:20 +0200 Subject: [PATCH] feat: keep the reader's side panel open across messages with its toggle in the toolbar --- .../viewer-toolbar-over-side-panel.test.tsx | 47 ++++- components/email/email-viewer.tsx | 198 +++++++++++------- 2 files changed, 169 insertions(+), 76 deletions(-) diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx index 924d60192..25b0b66be 100644 --- a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx +++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx @@ -1,12 +1,14 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, it, expect, vi } from 'vitest'; -import React from 'react'; +import React, { useEffect } from 'react'; import { cleanup } from '@testing-library/react'; import type { Email, Mailbox } from '@/lib/jmap/types'; // On a desktop the reader's top toolbar runs over the plugin side panel to // the right edge; the panel starts below it. On a phone the toolbar stays in -// the message column. Its menus close when a click goes into a frame. +// the message column. Its menus close when a click goes into a frame. The +// panel is the reader's, not the message's: its frame lives on from one +// message to the next, and its toggle sits at the end of the toolbar. const device = { isMobile: false }; vi.mock('@/hooks/use-media-query', () => ({ @@ -16,8 +18,16 @@ vi.mock('@/hooks/use-media-query', () => ({ vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), })); +const mounts: Record = {}; vi.mock('@/components/plugins/plugin-slot', () => ({ - PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-slot': name }), + PluginSlot: ({ name }: { name: string }) => { + useEffect(() => { + mounts[name] = (mounts[name] ?? 0) + 1; + // Once per mount on purpose: that is what the test counts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return React.createElement('div', { 'data-slot': name }); + }, })); vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'p', order: 100 }] : []), @@ -85,6 +95,37 @@ describe('the reader toolbar and the plugin side panel', () => { expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull(); }); + it('keeps the side panel frame while the user reads on, and between messages', () => { + const other = { ...email, id: 'e2', blobId: 'b2', threadId: 't2', subject: 'Second' } as unknown as Email; + const view = (props: { email: Email | null; isLoading: boolean }) => ( + {}} {...props} /> + ); + mounts['email-detail-sidebar'] = 0; + const { rerender } = render(view({ email, isLoading: false })); + expect(mounts['email-detail-sidebar']).toBe(1); + // The next message loading, then open; then none at all. + rerender(view({ email: null, isLoading: true })); + rerender(view({ email: other, isLoading: false })); + rerender(view({ email: null, isLoading: false })); + expect(screen.getByText('no_conversation_selected')).toBeTruthy(); + expect(document.querySelector('[data-slot="email-detail-sidebar"]')).toBeTruthy(); + expect(mounts['email-detail-sidebar']).toBe(1); + }); + + it('folds and unfolds the side panel from the toolbar', () => { + show(); + const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement; + // At the end of the toolbar, not in the panel. + expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull(); + expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide'); + act(() => { fireEvent.click(toggle); }); + expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull(); + // Folded, the panel leaves no strip of its own: the toolbar button brings it back. + expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1); + act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); }); + expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy(); + }); + it('keeps the toolbar in the message column on a phone', () => { device.isMobile = true; show(); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 28a635ac5..5d30882a7 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1582,6 +1582,17 @@ export function EmailViewer({ // click on the toggle, plus a new focusRequest for the slot. const detailPanelRef = useRef({ hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile }); detailPanelRef.current = { hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile }; + // The toolbar's panel button, like a plugin's request: opening focuses the panel. + const toggleDetailSidebar = () => { + if (sidebarCollapsed) { + setDetailSidebarCollapsed(false); + if (readerTooNarrow) setDetailSidebarForcedOpen(true); + setDetailFocusRequest((n) => n + 1); + } else { + setDetailSidebarCollapsed(true); + setDetailSidebarForcedOpen(false); + } + }; useEffect(() => onPanelRequest((request) => { if (request.slot !== 'email-detail-sidebar') return null; const state = detailPanelRef.current; @@ -3546,10 +3557,98 @@ export function EmailViewer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // The plugin side panel. One element in the same place whatever the viewer + // shows (a message, the next one loading, none), so its frame lives on while + // the user reads on instead of starting over with every message. + const panelToggleInToolbar = !!email; + const sidePanel = ( + <> + {hasDetailSidebar && !isMobile && ( +
+ {/* With a message open the toggle sits in its toolbar; without one, here. */} + {sidebarCollapsed && !panelToggleInToolbar && ( +
+ +
+ )} + {!sidebarCollapsed && ( + <> + {!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)} + /> + )} +
+ {!panelToggleInToolbar && ( +
+ +
+ )} +
+ +
+
+ + )} +
+ )} + + ); + const shell = (content: React.ReactNode) => ( +
+ {null} +
+ {content} + {sidePanel} + {null} +
+
+ ); + // Show loading skeleton while email is being fetched if (isLoading && !email) { - return ( -
+ return shell( +
{/* Loading Header Skeleton - gentler animation */}
@@ -3601,8 +3700,8 @@ export function EmailViewer({ const logoSrc = withBasePath(resolvedTheme === 'dark' ? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg' : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg'); - return ( -
+ return shell( +
+ return shell( +
@@ -4371,6 +4470,21 @@ export function EmailViewer({
)}
+ {/* The plugin side panel: shown or hidden for every message alike. */} + {hasDetailSidebar && !isMobile && ( + + )}
)} @@ -4412,6 +4526,7 @@ export function EmailViewer({ {toolbarPosition === 'top' && liftToolbar && topToolbar} {/* The message and the side panels, below the toolbar. Overlays anchor here. */}
+ <> {/* Mobile More menu sidebar overlay. Inside a Pro pane both pieces anchor to the viewer's relative root with absolute positioning: the viewport is desktop-sized there even though the pane is phone-sized, @@ -6441,72 +6556,9 @@ export function EmailViewer({ )} - {/* Plugin Detail Sidebar - resizable, collapsible */} - {hasDetailSidebar && !isMobile && ( - <> - {/* Collapse toggle when sidebar is collapsed */} - {sidebarCollapsed && ( -
- -
- )} - {!sidebarCollapsed && ( - <> - {!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)} - /> - )} -
-
- -
-
- -
-
- - )} - - )} - + + {sidePanel} + <> {bodyMention && ( )} - +
);