"use client"; // The composer-sidebar and composer-sidebar-right plugin panels. Each has a // width the user can drag (kept across composers) and a toggle to fold it // to a thin strip. When the composer is too narrow for an open panel next // to it, the panel folds by itself (right first); opening it anyway lays it // over the composer instead of squeezing the message into a column nobody // can write in. import { useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { onPanelRequest, type PanelRequest } from "@/lib/plugin-sandbox/panel-requests"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { cn } from "@/lib/utils"; /** Width of an open panel until the user drags it. */ export const COMPOSER_SIDEBAR_WIDTH = 260; export const MIN_COMPOSER_SIDEBAR_WIDTH = 220; export const MAX_COMPOSER_SIDEBAR_WIDTH = 480; /** Width of a folded panel (the strip with its toggle). */ export const COMPOSER_SIDEBAR_STRIP = 37; /** Below this window width the panels are not shown at all (the `md` breakpoint). */ export const COMPOSER_SIDEBAR_MIN_WINDOW = 768; /** * Narrowest the composer itself may get next to open panels: below this its * bottom bar (Attach, Templates, Options, Discard, Send) runs into itself. */ export const MIN_COMPOSER_WIDTH = 560; export type ComposerSidebarSide = "left" | "right"; /** docked: open beside the composer; folded: a strip; overlay: open over the composer. */ export type ComposerSidebarMode = "docked" | "folded" | "overlay"; interface SideState { present: boolean; /** Folded by the user. */ collapsed: boolean; /** Opened by the user while there was no room for it. */ overlayOpen?: boolean; /** The user's width for it; the default when not dragged yet. */ width?: number; } /** * How each panel is shown in a composer `width` px wide (0 = not measured * yet: everything the user left open is shown open). The left panel gets * room first, since the right one is folded first. */ export function composerSidebarModes( width: number, left: SideState, right: SideState, ): Record { const present = (left.present ? 1 : 0) + (right.present ? 1 : 0); let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity; const place = (side: SideState): ComposerSidebarMode | null => { if (!side.present) return null; if (side.collapsed) return "folded"; const extra = (side.width ?? COMPOSER_SIDEBAR_WIDTH) - COMPOSER_SIDEBAR_STRIP; if (extra <= room) { room -= extra; return "docked"; } return side.overlayOpen ? "overlay" : "folded"; }; const l = place(left); return { left: l, right: place(right) }; } const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`; const widthKey = (side: ComposerSidebarSide) => `composerPluginSidebarWidth:${side}`; /** 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 { return Math.max(MIN_COMPOSER_SIDEBAR_WIDTH, Math.min(MAX_COMPOSER_SIDEBAR_WIDTH, Math.round(width))); } function readWidth(side: ComposerSidebarSide): number { if (typeof window === "undefined") return COMPOSER_SIDEBAR_WIDTH; try { const n = parseInt(localStorage.getItem(widthKey(side)) ?? "", 10); return Number.isFinite(n) ? clampSidebarWidth(n) : COMPOSER_SIDEBAR_WIDTH; } catch { return COMPOSER_SIDEBAR_WIDTH; } } /** * Layout state of both panels for a composer whose root element is `root`. * `composerId` is the id plugins know this composer by; their * api.ui.openPanel requests for it are answered here. */ export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) { 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); // 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>(() => ({ left: readCollapsed("left"), right: readCollapsed("right"), })); const collapsed: Record = { left: chosen.left ?? leftOffers.some((o) => o.startFolded), right: chosen.right ?? rightOffers.some((o) => o.startFolded), }; const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false }); const [widths, setWidths] = useState>(() => ({ left: readWidth("left"), right: readWidth("right") })); useEffect(() => { if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width)); }); observer.observe(root); return () => observer.disconnect(); }, [root, hasLeft, hasRight]); const modes = composerSidebarModes( width, { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left, width: widths.left }, { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right, width: widths.right }, ); /** The user dragged a panel's edge. */ const setSideWidth = (side: ComposerSidebarSide, value: number) => { const next = clampSidebarWidth(value); setWidths((prev) => (prev[side] === next ? prev : { ...prev, [side]: next })); try { localStorage.setItem(widthKey(side), String(next)); } catch { /* storage blocked */ } }; const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => { setChosen((prev) => ({ ...prev, [side]: value })); try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ } }; /** Open a folded panel: beside the composer when it fits, over it otherwise. */ const open = (side: ComposerSidebarSide) => { const next = { ...collapsed, [side]: false }; const fits = composerSidebarModes( width, { present: hasLeft, collapsed: next.left, width: widths.left }, { present: hasRight, collapsed: next.right, width: widths.right }, )[side] === "docked"; setSideCollapsed(side, false); setOverlayOpen((prev) => ({ ...prev, [side]: !fits })); }; const close = (side: ComposerSidebarSide) => { setSideCollapsed(side, true); setOverlayOpen((prev) => ({ ...prev, [side]: false })); }; // Plugins opening their panel (api.ui.openPanel): counts up per side when // it opens, and reaches the slot as `focusRequest`. const [focusRequests, setFocusRequests] = useState>({ left: 0, right: 0 }); const latest = useRef({ modes, open, close, composerId, hasLeft, hasRight }); latest.current = { modes, open, close, composerId, hasLeft, hasRight }; useEffect(() => onPanelRequest((request: PanelRequest) => { const side: ComposerSidebarSide | null = request.slot === "composer-sidebar" ? "left" : request.slot === "composer-sidebar-right" ? "right" : null; if (!side) return null; const state = latest.current; if (request.composerId && request.composerId !== state.composerId) return null; if (!(side === "left" ? state.hasLeft : state.hasRight)) return null; // On a phone the panels are not on screen (hidden below md): the plugin // then falls back to a dialog. if (typeof window !== "undefined" && window.innerWidth < COMPOSER_SIDEBAR_MIN_WINDOW) return null; const isOpen = state.modes[side] === "docked" || state.modes[side] === "overlay"; const wantOpen = request.mode === "open" ? true : request.mode === "close" ? false : !isOpen; if (wantOpen) { if (!isOpen) state.open(side); setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 })); } else if (isOpen) { state.close(side); } return wantOpen; }), []); return { modes, open, close, focusRequests, widths, setSideWidth }; } /** One plugin panel of the composer, in the mode useComposerSidebars picked. */ export function ComposerPluginSidebar({ side, mode, onOpen, onClose, extraProps, width = COMPOSER_SIDEBAR_WIDTH, onWidthChange, }: { side: ComposerSidebarSide; mode: ComposerSidebarMode | null; onOpen: () => void; onClose: () => void; /** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */ extraProps?: Record; width?: number; /** The user drags the panel's edge; without it the panel has no handle. */ onWidthChange?: (width: number) => void; }) { const t = useTranslations("email_viewer"); const dragStart = useRef(width); if (!mode) return null; const border = side === "left" ? "border-e" : "border-s"; const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen; const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose; if (mode === "folded") { return (
); } // Docked beside the composer: a handle on the panel's inner edge. Dragging // towards the composer widens the panel (the delta flips for the right one). const handle = mode === "docked" && onWidthChange ? ( { dragStart.current = width; }} onResize={(delta) => onWidthChange(dragStart.current + (side === "left" ? delta : -delta))} onResizeEnd={() => { dragStart.current = width; }} onDoubleClick={() => onWidthChange(COMPOSER_SIDEBAR_WIDTH)} /> ) : null; return ( <> {side === "right" && handle}
{side === "left" ? : }
{side === "left" && handle} ); }