fix: keep a plugin side panel from squeezing the message too narrow to read

This commit is contained in:
Linus Rath
2026-10-14 03:00:46 +02:00
parent 3bfc0e69d0
commit fe4f809092
29 changed files with 212 additions and 51 deletions
@@ -0,0 +1,78 @@
import { render, screen, act, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
// With a plugin panel open at 280 px next to a 300 px message (three panes
// on a laptop), the subject broke after every few letters. The panel now
// starts collapsed when it would leave the message under 480 px.
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-testid': `slot-${name}` }),
}));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'notes', order: 100 }] : []),
}));
let reportWidth = 0;
const observers: Array<(entries: Array<{ contentRect: { width: number } }>) => void> = [];
class FakeResizeObserver {
constructor(private cb: (entries: Array<{ contentRect: { width: number } }>) => void) { observers.push(cb); }
observe() { this.cb([{ contentRect: { width: reportWidth } }]); }
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
} as unknown as Email;
function renderViewer() {
return render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
}
beforeEach(() => {
localStorage.clear();
observers.length = 0;
});
describe('plugin detail panel and narrow messages', () => {
it('starts collapsed when it would leave the message too narrow', () => {
reportWidth = 640;
renderViewer();
expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
});
it('opens anyway when the user asks', () => {
reportWidth = 640;
renderViewer();
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
});
it('is open when there is room', () => {
reportWidth = 1100;
renderViewer();
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
});
});
+35 -6
View File
@@ -961,6 +961,9 @@ function DetailsCategorySlot({ email, category }: { email: EmailReadView | null;
return <PluginSlot name="email-details-section" className="mt-2 empty:mt-0" extraProps={props} />;
}
/** Narrowest the message may get next to a plugin panel before the panel collapses. */
const MIN_READER_WIDTH = 480;
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
<div>
@@ -1422,6 +1425,25 @@ export function EmailViewer({
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
// 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<HTMLDivElement>(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;
const ro = new ResizeObserver((entries) => {
const width = Math.round(entries[0]?.contentRect.width ?? 0);
setViewerWidth(width);
});
ro.observe(el);
return () => ro.disconnect();
}, [hasDetailSidebar]);
const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH;
const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
@@ -3993,6 +4015,7 @@ export function EmailViewer({
return (
<div
ref={viewerRootRef}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
>
@@ -6073,20 +6096,23 @@ export function EmailViewer({
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
{detailSidebarCollapsed && (
{sidebarCollapsed && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(false)}
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label="Expand panel"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!detailSidebarCollapsed && (
{!sidebarCollapsed && (
<>
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
@@ -6105,9 +6131,12 @@ export function EmailViewer({
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(true)}
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label="Collapse panel"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "تم نسخ الرمز",
"copy_failed": "تعذّر نسخ الرمز"
},
"mv_position": "{index} من {total}"
"mv_position": "{index} من {total}",
"plugin_panel_show": "إظهار لوحة الإضافة",
"plugin_panel_hide": "إخفاء لوحة الإضافة"
},
"email_composer": {
"minimize": "تصغير",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Codi copiat",
"copy_failed": "No s'ha pogut copiar el codi"
},
"mv_position": "{index} de {total}"
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostra el tauler del connector",
"plugin_panel_hide": "Amaga el tauler del connector"
},
"email_composer": {
"minimize": "Minimitza",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kód zkopírován",
"copy_failed": "Kód se nepodařilo zkopírovat"
},
"mv_position": "{index} z {total}"
"mv_position": "{index} z {total}",
"plugin_panel_show": "Zobrazit panel pluginu",
"plugin_panel_hide": "Skrýt panel pluginu"
},
"email_composer": {
"minimize": "Minimalizovat",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kode kopieret",
"copy_failed": "Koden kunne ikke kopieres"
},
"mv_position": "{index} af {total}"
"mv_position": "{index} af {total}",
"plugin_panel_show": "Vis pluginpanel",
"plugin_panel_hide": "Skjul pluginpanel"
},
"email_composer": {
"minimize": "Minimer",
+12 -10
View File
@@ -359,15 +359,15 @@
"external_content_warning": "Bilder und externe Inhalte wurden blockiert",
"load_external_content": "Bilder laden",
"trust_sender": "Diesem Absender immer vertrauen",
"sender_check": {
"unverified_label": "Absender nicht bestätigt",
"failed_label": "Absenderprüfung fehlgeschlagen",
"unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.",
"unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.",
"failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.",
"failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.",
"caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten."
},
"sender_check": {
"unverified_label": "Absender nicht bestätigt",
"failed_label": "Absenderprüfung fehlgeschlagen",
"unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.",
"unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.",
"failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.",
"failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.",
"caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten."
},
"back_to_list": "Zurück zur Liste",
"view_contact": "Kontakt anzeigen",
"message_details": "Nachrichtendetails",
@@ -620,7 +620,9 @@
"copied": "Code kopiert",
"copy_failed": "Code konnte nicht kopiert werden"
},
"mv_position": "{index} von {total}"
"mv_position": "{index} von {total}",
"plugin_panel_show": "Plugin-Bereich einblenden",
"plugin_panel_hide": "Plugin-Bereich ausblenden"
},
"email_composer": {
"minimize": "Minimieren",
+12 -10
View File
@@ -359,15 +359,15 @@
"external_content_warning": "Images and external content have been blocked",
"load_external_content": "Load images",
"trust_sender": "Always trust this sender",
"sender_check": {
"unverified_label": "Unverified sender",
"failed_label": "Sender check failed",
"unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.",
"unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.",
"failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.",
"failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.",
"caution": "Be careful with links, attachments and requests for passwords or payment details."
},
"sender_check": {
"unverified_label": "Unverified sender",
"failed_label": "Sender check failed",
"unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.",
"unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.",
"failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.",
"failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.",
"caution": "Be careful with links, attachments and requests for passwords or payment details."
},
"back_to_list": "Back to list",
"view_contact": "View contact",
"message_details": "Message Details",
@@ -620,7 +620,9 @@
"copied": "Code copied",
"copy_failed": "Could not copy the code"
},
"mv_position": "{index} of {total}"
"mv_position": "{index} of {total}",
"plugin_panel_show": "Show plugin panel",
"plugin_panel_hide": "Hide plugin panel"
},
"email_composer": {
"minimize": "Minimize",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Código copiado",
"copy_failed": "No se pudo copiar el código"
},
"mv_position": "{index} de {total}"
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostrar el panel del complemento",
"plugin_panel_hide": "Ocultar el panel del complemento"
},
"email_composer": {
"minimize": "Minimizar",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "کد کپی شد",
"copy_failed": "کپی کد ممکن نشد"
},
"mv_position": "{index} از {total}"
"mv_position": "{index} از {total}",
"plugin_panel_show": "نمایش پنل افزونه",
"plugin_panel_hide": "پنهان کردن پنل افزونه"
},
"email_composer": {
"minimize": "کوچک کردن",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Code copié",
"copy_failed": "Impossible de copier le code"
},
"mv_position": "{index} sur {total}"
"mv_position": "{index} sur {total}",
"plugin_panel_show": "Afficher le panneau du plugin",
"plugin_panel_hide": "Masquer le panneau du plugin"
},
"email_composer": {
"minimize": "Réduire",
+3 -1
View File
@@ -585,7 +585,9 @@
"copied": "הקוד הועתק",
"copy_failed": "לא ניתן להעתיק את הקוד"
},
"mv_position": "{index} מתוך {total}"
"mv_position": "{index} מתוך {total}",
"plugin_panel_show": "הצגת חלונית התוסף",
"plugin_panel_hide": "הסתרת חלונית התוסף"
},
"email_composer": {
"minimize": "מזעור",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kód másolva",
"copy_failed": "Nem sikerült másolni a kódot"
},
"mv_position": "{index} / {total}"
"mv_position": "{index} / {total}",
"plugin_panel_show": "Bővítménypanel megjelenítése",
"plugin_panel_hide": "Bővítménypanel elrejtése"
},
"email_composer": {
"minimize": "Kis méret",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Codice copiato",
"copy_failed": "Impossibile copiare il codice"
},
"mv_position": "{index} di {total}"
"mv_position": "{index} di {total}",
"plugin_panel_show": "Mostra il pannello del plugin",
"plugin_panel_hide": "Nascondi il pannello del plugin"
},
"email_composer": {
"minimize": "Riduci a icona",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "コードをコピーしました",
"copy_failed": "コードをコピーできませんでした"
},
"mv_position": "{index} / {total}"
"mv_position": "{index} / {total}",
"plugin_panel_show": "プラグインパネルを表示",
"plugin_panel_hide": "プラグインパネルを隠す"
},
"email_composer": {
"minimize": "最小化",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "코드를 복사했습니다",
"copy_failed": "코드를 복사할 수 없습니다"
},
"mv_position": "{index} / {total}"
"mv_position": "{index} / {total}",
"plugin_panel_show": "플러그인 패널 표시",
"plugin_panel_hide": "플러그인 패널 숨기기"
},
"email_composer": {
"minimize": "최소화",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kods nokopēts",
"copy_failed": "Neizdevās nokopēt kodu"
},
"mv_position": "{index} no {total}"
"mv_position": "{index} no {total}",
"plugin_panel_show": "Rādīt spraudņa paneli",
"plugin_panel_hide": "Slēpt spraudņa paneli"
},
"email_composer": {
"minimize": "Minimizēt",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Код хуулагдлаа",
"copy_failed": "Кодыг хуулж чадсангүй"
},
"mv_position": "{total}-с {index}"
"mv_position": "{total}-с {index}",
"plugin_panel_show": "Залгаасын самбарыг харуулах",
"plugin_panel_hide": "Залгаасын самбарыг нуух"
},
"email_composer": {
"minimize": "Багасгах",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kode kopiert",
"copy_failed": "Kunne ikke kopiere koden"
},
"mv_position": "{index} av {total}"
"mv_position": "{index} av {total}",
"plugin_panel_show": "Vis tilleggspanel",
"plugin_panel_hide": "Skjul tilleggspanel"
},
"email_composer": {
"minimize": "Minimer",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Code gekopieerd",
"copy_failed": "Kan de code niet kopiëren"
},
"mv_position": "{index} van {total}"
"mv_position": "{index} van {total}",
"plugin_panel_show": "Plug-inpaneel tonen",
"plugin_panel_hide": "Plug-inpaneel verbergen"
},
"email_composer": {
"minimize": "Minimaliseren",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Skopiowano kod",
"copy_failed": "Nie udało się skopiować kodu"
},
"mv_position": "{index} z {total}"
"mv_position": "{index} z {total}",
"plugin_panel_show": "Pokaż panel wtyczki",
"plugin_panel_hide": "Ukryj panel wtyczki"
},
"email_composer": {
"minimize": "Minimalizuj",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Código copiado",
"copy_failed": "Não foi possível copiar o código"
},
"mv_position": "{index} de {total}"
"mv_position": "{index} de {total}",
"plugin_panel_show": "Mostrar o painel do plugin",
"plugin_panel_hide": "Ocultar o painel do plugin"
},
"email_composer": {
"minimize": "Minimizar",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Cod copiat",
"copy_failed": "Codul nu a putut fi copiat"
},
"mv_position": "{index} din {total}"
"mv_position": "{index} din {total}",
"plugin_panel_show": "Afișează panoul pluginului",
"plugin_panel_hide": "Ascunde panoul pluginului"
},
"email_composer": {
"minimize": "Minimizează",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Код скопирован",
"copy_failed": "Не удалось скопировать код"
},
"mv_position": "{index} из {total}"
"mv_position": "{index} из {total}",
"plugin_panel_show": "Показать панель плагина",
"plugin_panel_hide": "Скрыть панель плагина"
},
"email_composer": {
"minimize": "Свернуть",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kód skopírovaný",
"copy_failed": "Kód sa nepodarilo skopírovať"
},
"mv_position": "{index} z {total}"
"mv_position": "{index} z {total}",
"plugin_panel_show": "Zobraziť panel doplnku",
"plugin_panel_hide": "Skryť panel doplnku"
},
"email_composer": {
"minimize": "Minimalizovať",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Kod kopyalandı",
"copy_failed": "Kod kopyalanamadı"
},
"mv_position": "{index} / {total}"
"mv_position": "{index} / {total}",
"plugin_panel_show": "Eklenti panelini göster",
"plugin_panel_hide": "Eklenti panelini gizle"
},
"email_composer": {
"minimize": "Simge durumuna küçült",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "Код скопійовано",
"copy_failed": "Не вдалося скопіювати код"
},
"mv_position": "{index} з {total}"
"mv_position": "{index} з {total}",
"plugin_panel_show": "Показати панель плагіна",
"plugin_panel_hide": "Сховати панель плагіна"
},
"email_composer": {
"minimize": "Згорнути",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "驗證碼已複製",
"copy_failed": "無法複製驗證碼"
},
"mv_position": "第 {index} 封,共 {total} 封"
"mv_position": "第 {index} 封,共 {total} 封",
"plugin_panel_show": "顯示外掛程式面板",
"plugin_panel_hide": "隱藏外掛程式面板"
},
"email_composer": {
"minimize": "最小化",
+3 -1
View File
@@ -620,7 +620,9 @@
"copied": "验证码已复制",
"copy_failed": "无法复制验证码"
},
"mv_position": "第 {index} 封,共 {total} 封"
"mv_position": "第 {index} 封,共 {total} 封",
"plugin_panel_show": "显示插件面板",
"plugin_panel_hide": "隐藏插件面板"
},
"email_composer": {
"minimize": "最小化",