mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: keep the reader's side panel open across messages with its toggle in the toolbar
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user