mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let a plugin panel start folded until the user or the plugin opens it
This commit is contained in:
@@ -647,6 +647,22 @@ describe('composer-sidebar and composer-sidebar-right', () => {
|
||||
expect(open).toBeNull();
|
||||
});
|
||||
|
||||
it('start folded when the plugin asks for it, until the user or the plugin opens it', () => {
|
||||
stubWidth(1400);
|
||||
offerSlots('p1', ['composer-sidebar-right'], { startFolded: ['composer-sidebar-right'] });
|
||||
const { unmount } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
|
||||
act(() => { requestPanel('p1', 'composer-sidebar-right', 'open', undefined); });
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('docked');
|
||||
unmount();
|
||||
|
||||
// Opened once: it stays open for the next composer.
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('docked');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
|
||||
});
|
||||
|
||||
it('open a folded panel over the composer when there is no room beside it', () => {
|
||||
stubWidth(600);
|
||||
offerSlots('p1', ['composer-sidebar-right']);
|
||||
|
||||
@@ -163,6 +163,18 @@ describe('email-detail-sidebar', () => {
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
|
||||
});
|
||||
|
||||
it('starts folded when the plugin asks for it, and remembers the user opening it', () => {
|
||||
stubWidth(1000);
|
||||
offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] });
|
||||
const { unmount } = viewer(textMail('Hi'));
|
||||
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked');
|
||||
unmount();
|
||||
viewer(textMail('Hi'));
|
||||
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('docks next to the message when there is room', () => {
|
||||
stubWidth(1000);
|
||||
offerSlots('p1', ['email-detail-sidebar']);
|
||||
|
||||
@@ -71,9 +71,13 @@ export function composerSidebarModes(
|
||||
const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
|
||||
const widthKey = (side: ComposerSidebarSide) => `composerPluginSidebarWidth:${side}`;
|
||||
|
||||
function readCollapsed(side: ComposerSidebarSide): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
|
||||
/** The user's choice for a side; null until they fold or open it once. */
|
||||
function readCollapsed(side: ComposerSidebarSide): boolean | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey(side));
|
||||
return stored === "1" ? true : stored === "0" ? false : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function clampSidebarWidth(width: number): number {
|
||||
@@ -94,13 +98,21 @@ function readWidth(side: ComposerSidebarSide): number {
|
||||
* api.ui.openPanel requests for it are answered here.
|
||||
*/
|
||||
export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) {
|
||||
const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
|
||||
const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
|
||||
const leftOffers = usePluginSlotOffers("composer-sidebar");
|
||||
const rightOffers = usePluginSlotOffers("composer-sidebar-right");
|
||||
const hasLeft = leftOffers.length > 0;
|
||||
const hasRight = rightOffers.length > 0;
|
||||
const [width, setWidth] = useState(0);
|
||||
const [collapsed, setCollapsed] = useState<Record<ComposerSidebarSide, boolean>>(() => ({
|
||||
// The user's choice per side; until they make one, a panel whose plugin
|
||||
// asked to start folded (a chat opened on demand) is folded, the rest open.
|
||||
const [chosen, setChosen] = useState<Record<ComposerSidebarSide, boolean | null>>(() => ({
|
||||
left: readCollapsed("left"),
|
||||
right: readCollapsed("right"),
|
||||
}));
|
||||
const collapsed: Record<ComposerSidebarSide, boolean> = {
|
||||
left: chosen.left ?? leftOffers.some((o) => o.startFolded),
|
||||
right: chosen.right ?? rightOffers.some((o) => o.startFolded),
|
||||
};
|
||||
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ left: false, right: false });
|
||||
const [widths, setWidths] = useState<Record<ComposerSidebarSide, number>>(() => ({ left: readWidth("left"), right: readWidth("right") }));
|
||||
|
||||
@@ -127,7 +139,7 @@ export function useComposerSidebars(root: HTMLElement | null, composerId: string
|
||||
};
|
||||
|
||||
const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
|
||||
setCollapsed((prev) => ({ ...prev, [side]: value }));
|
||||
setChosen((prev) => ({ ...prev, [side]: value }));
|
||||
try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
|
||||
};
|
||||
|
||||
|
||||
@@ -1531,10 +1531,16 @@ export function EmailViewer({
|
||||
emailIdRef.current = email?.id;
|
||||
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
|
||||
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
|
||||
// The user's choice; until they make one, a panel whose plugin asked to
|
||||
// start folded (a chat opened on demand) is folded, the rest open.
|
||||
const [detailSidebarChoice, setDetailSidebarCollapsed] = useState<boolean | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem('emailDetailSidebarCollapsed');
|
||||
return stored === '1' ? true : stored === '0' ? false : null;
|
||||
} catch { return null; }
|
||||
});
|
||||
const detailSidebarCollapsed = detailSidebarChoice ?? detailSlots.some((o) => o.startFolded);
|
||||
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
|
||||
if (typeof window === 'undefined') return 280;
|
||||
try {
|
||||
@@ -1588,8 +1594,9 @@ export function EmailViewer({
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [detailSidebarCollapsed]);
|
||||
if (detailSidebarChoice === null) return;
|
||||
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarChoice ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [detailSidebarChoice]);
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
|
||||
}, [detailSidebarWidth]);
|
||||
|
||||
@@ -15,14 +15,14 @@ export function lastSlotProps(slot: string): Record<string, unknown> {
|
||||
return lastProps.get(slot) ?? {};
|
||||
}
|
||||
|
||||
/** Registers a fake plugin that offers the given slots; `fill` names the panel slots whose frame fills the panel. */
|
||||
export function offerSlots(pluginId: string, slots: string[], { fill = [] }: { fill?: string[] } = {}): void {
|
||||
/** Registers a fake plugin that offers the given slots; `fill` names the panel slots whose frame fills the panel, `startFolded` those that start folded. */
|
||||
export function offerSlots(pluginId: string, slots: string[], { fill = [], startFolded = [] }: { fill?: string[]; startFolded?: string[] } = {}): void {
|
||||
register({
|
||||
plugin: { id: pluginId, name: pluginId },
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: {},
|
||||
slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false, ...(fill.includes(name) ? { fill: true } : {}) })),
|
||||
slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false, ...(fill.includes(name) ? { fill: true } : {}), ...(startFolded.includes(name) ? { startFolded: true } : {}) })),
|
||||
hookDisposables: [],
|
||||
} as unknown as ActivePlugin);
|
||||
offered.add(pluginId);
|
||||
|
||||
@@ -172,7 +172,7 @@ export interface SlotOptions {
|
||||
|
||||
export interface InitDoneInfo {
|
||||
hooks: string[];
|
||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }>;
|
||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>;
|
||||
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface InitDoneMsg {
|
||||
/** Hook names the plugin registered. The host installs proxy handlers. */
|
||||
hooks: string[];
|
||||
/** Slots the plugin claims. Used by the host to know when a slot is offered. `fill`: a panel slot that fills its panel (see slot-layout.ts). */
|
||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }>;
|
||||
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>;
|
||||
/**
|
||||
* Keyboard shortcuts the plugin declares. The host installs a global
|
||||
* keydown listener that dispatches to the `shortcut:<id>` hook when the
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SlotOffer {
|
||||
hasShouldShow: boolean;
|
||||
/** The frame fills its panel (panel slots only; see slot-layout.ts). */
|
||||
fill?: boolean;
|
||||
/** The panel starts folded until the user or the plugin opens it (panel slots only). */
|
||||
startFolded?: boolean;
|
||||
}
|
||||
|
||||
export interface ActivePlugin {
|
||||
@@ -34,6 +36,7 @@ export interface ResolvedSlotOffer {
|
||||
order: number;
|
||||
hasShouldShow: boolean;
|
||||
fill?: boolean;
|
||||
startFolded?: boolean;
|
||||
}
|
||||
|
||||
const active = new Map<string, ActivePlugin>();
|
||||
@@ -83,7 +86,7 @@ export function offersForSlot(slot: SlotName): readonly ResolvedSlotOffer[] {
|
||||
for (const entry of active.values()) {
|
||||
for (const offer of entry.slotOffers) {
|
||||
if (offer.name === slot) {
|
||||
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow, ...(offer.fill ? { fill: true } : {}) });
|
||||
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow, ...(offer.fill ? { fill: true } : {}), ...(offer.startFolded ? { startFolded: true } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-securit
|
||||
// ─── Module-scope state ──────────────────────────────────────
|
||||
|
||||
interface PluginExports {
|
||||
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean }>;
|
||||
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>;
|
||||
hooks?: Record<string, (...args: unknown[]) => unknown>;
|
||||
/**
|
||||
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
|
||||
@@ -913,7 +913,7 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
}
|
||||
|
||||
// Enumerate slot offers.
|
||||
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean }> = [];
|
||||
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }> = [];
|
||||
const slots = exports.slots ?? {};
|
||||
for (const [name, def] of Object.entries(slots)) {
|
||||
if (def && typeof def.component === 'function') {
|
||||
@@ -923,6 +923,8 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
order: typeof def.order === 'number' ? def.order : 100,
|
||||
// A panel that fills its panel instead of growing with its content.
|
||||
...(def.fill === true && isFillableSlot(name) ? { fill: true } : {}),
|
||||
// A panel the user opens when they want it (a chat), not on every message.
|
||||
...(def.startFolded === true && isFillableSlot(name) ? { startFolded: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user