feat: keep the reader's side panel open across messages with its toggle in the toolbar

This commit is contained in:
Linus Rath
2026-10-20 19:12:20 +02:00
parent b825a54a73
commit 976f3ea543
2 changed files with 169 additions and 76 deletions
@@ -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<string, number> = {};
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 }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...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();
+125 -73
View File
@@ -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 && (
<div className="contents" data-reader-side-panel="">
{/* With a message open the toggle sits in its toolbar; without one, here. */}
{sidebarCollapsed && !panelToggleInToolbar && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
{!panelToggleInToolbar && (
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
</div>
</>
)}
</div>
)}
</>
);
const shell = (content: React.ReactNode) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{null}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
{null}
</div>
</div>
);
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
return (
<div className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
{/* Loading Header Skeleton - gentler animation */}
<div className="bg-background border-b border-border">
<div className="px-4 lg:px-6 py-3 lg:py-4">
@@ -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 (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
@@ -3646,8 +3745,8 @@ export function EmailViewer({
// Rendered in Pro (embedded) too: a silent void here read as "broken"
// in a split pane - the empty reading pane should always say what it is
// and offer a way forward.
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
@@ -4371,6 +4470,21 @@ export function EmailViewer({
</div>
)}
</div>
{/* The plugin side panel: shown or hidden for every message alike. */}
{hasDetailSidebar && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
)}
</div>
)}
</>
@@ -4412,6 +4526,7 @@ export function EmailViewer({
{toolbarPosition === 'top' && liftToolbar && topToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
<>
{/* 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({
)}
<ReaderSelectionMenu menu={selectionMenu} />
{/* Plugin Detail Sidebar - resizable, collapsible */}
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
{sidebarCollapsed && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
</div>
</>
)}
</>
)}
</>
{sidePanel}
<>
{bodyMention && (
<RecipientCardPopover
anchor={bodyMention.anchor}
@@ -6563,7 +6615,7 @@ export function EmailViewer({
getFileContent={getInAppPreviewContent}
/>
)}
</>
</div>
</div>
);