mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'main' into feat/desktop-offline
This commit is contained in:
@@ -1264,7 +1264,7 @@ function LoginPageContent() {
|
||||
// Demo-only mode: show only a large demo login button
|
||||
if (demoMode && !isAddAccountMode) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4 py-16">
|
||||
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
|
||||
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
@@ -1417,7 +1417,7 @@ function LoginPageContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4 py-16">
|
||||
<MobileHandoffConsent
|
||||
url={pendingHandoff?.url ?? null}
|
||||
resolveUrl={pendingHandoff?.resolveUrl ?? null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Until the session is up the Pro page draws the same skeleton the server
|
||||
@@ -10,7 +10,9 @@ vi.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device, atLeast: (px: number) => `(width >= ${px}px)` }));
|
||||
const routerReplace = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ replace: routerReplace }) }));
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: {
|
||||
@@ -60,6 +62,8 @@ vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
vi.mock('@/hooks/use-deep-link-url', () => ({ useDeepLinkUrl: () => {} }));
|
||||
const { default: ProHome } = await import('../page');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { useProTabStore, takeHandedOffComposeDraft } = await import('@/stores/pro-tab-store');
|
||||
const { EMAIL_IDS_DRAG_MIME, PRO_TAB_DRAG_MIME } = await import('@/components/pro/pro-shell-drop');
|
||||
|
||||
const replace = vi.fn();
|
||||
|
||||
@@ -111,4 +115,104 @@ describe('Pro page gate', () => {
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.querySelector('[data-testid="mail-app"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('when the device flags say the window is not desktop-wide', () => {
|
||||
const setWindowWide = (matches: boolean) => {
|
||||
window.matchMedia = vi.fn(() => ({ matches, addEventListener: () => {}, removeEventListener: () => {} })) as never;
|
||||
};
|
||||
const original = window.matchMedia;
|
||||
afterEach(() => { window.matchMedia = original; });
|
||||
|
||||
beforeEach(() => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
Object.assign(device, { isMobile: false, isTablet: true, isDesktop: false });
|
||||
const compose = { id: 'c1', kind: 'compose' as const, labelKey: '', closeable: true, paneId: 'main' as const, composeData: { sessionId: 1, mode: 'compose' as const, title: 'x', initialData: { subject: 'Typed', mode: 'compose' } as never } };
|
||||
useProTabStore.setState({
|
||||
tabs: [{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' }, compose],
|
||||
activeTabId: 'c1', activeSplitTabId: null, focusedPaneId: 'main', splitOrientation: null, splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail', 'c1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('goes to the standard page without a reload, taking the compose tab in front along', async () => {
|
||||
setWindowWide(false);
|
||||
render(<ProHome />);
|
||||
await act(async () => {});
|
||||
expect(routerReplace).toHaveBeenCalledWith('/');
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
expect(takeHandedOffComposeDraft()?.draft.subject).toBe('Typed');
|
||||
expect(useProTabStore.getState().tabs.some((t) => t.id === 'c1')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays when the window is wide and only the flags are behind', async () => {
|
||||
setWindowWide(true);
|
||||
render(<ProHome />);
|
||||
await act(async () => {});
|
||||
expect(routerReplace).not.toHaveBeenCalled();
|
||||
expect(takeHandedOffComposeDraft()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a tab dropped on a surface that stops every drop', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const };
|
||||
const other = { id: 'cal', kind: 'calendar' as const, labelKey: 'calendar', closeable: true, paneId: 'main' as const };
|
||||
const split = { id: 'files', kind: 'files' as const, labelKey: 'files', closeable: true, paneId: 'split' as const };
|
||||
useProTabStore.setState({
|
||||
tabs: [main, other, split], activeTabId: main.id, activeSplitTabId: split.id, focusedPaneId: 'main',
|
||||
splitOrientation: 'vertical', splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail', 'cal', 'files'],
|
||||
});
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
const surface = container.querySelector('[data-testid="mail-app"]')!;
|
||||
for (const type of ['dragover', 'drop']) {
|
||||
surface.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation(); });
|
||||
}
|
||||
const dataTransfer = { types: [PRO_TAB_DRAG_MIME], getData: () => 'cal', dropEffect: '' };
|
||||
// No layout in jsdom: the position resolves to the right half, the split.
|
||||
fireEvent.dragOver(surface, { dataTransfer });
|
||||
fireEvent.drop(surface, { dataTransfer });
|
||||
expect(useProTabStore.getState().tabs.find((t) => t.id === 'cal')?.paneId).toBe('split');
|
||||
});
|
||||
|
||||
it('stops a message row dropped where it can go nowhere, so no field takes its subject', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const };
|
||||
useProTabStore.setState({
|
||||
tabs: [main], activeTabId: main.id, activeSplitTabId: null, focusedPaneId: 'main',
|
||||
splitOrientation: null, splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail'],
|
||||
});
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
const surface = container.querySelector('[data-testid="mail-app"]')!;
|
||||
const dataTransfer = { types: [EMAIL_IDS_DRAG_MIME, 'text/plain'], getData: () => '["m1"]', dropEffect: '' };
|
||||
// No layout in jsdom: the position resolves to the middle, not a split edge.
|
||||
const notPrevented = fireEvent.drop(surface, { dataTransfer });
|
||||
expect(notPrevented).toBe(false);
|
||||
expect(useProTabStore.getState().tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves a tab dropped on a tab strip to the strip', async () => {
|
||||
auth.state.isAuthenticated = true;
|
||||
auth.state.client = {};
|
||||
const main = { id: 'home-mail', kind: 'mail' as const, labelKey: 'mail', closeable: false, paneId: 'main' as const };
|
||||
const other = { id: 'cal', kind: 'calendar' as const, labelKey: 'calendar', closeable: true, paneId: 'main' as const };
|
||||
const split = { id: 'files', kind: 'files' as const, labelKey: 'files', closeable: true, paneId: 'split' as const };
|
||||
useProTabStore.setState({
|
||||
tabs: [main, other, split], activeTabId: main.id, activeSplitTabId: split.id, focusedPaneId: 'main',
|
||||
splitOrientation: 'vertical', splitSide: 'right', readerTabId: null, loadedTabIds: ['home-mail', 'cal', 'files'],
|
||||
});
|
||||
const { container } = render(<ProHome />);
|
||||
await act(async () => {});
|
||||
const strip = container.querySelector('[role="tablist"]')!;
|
||||
strip.setAttribute('data-pane-strip', 'main');
|
||||
const seen = vi.fn((e: Event) => { e.preventDefault(); });
|
||||
strip.addEventListener('drop', seen);
|
||||
const dataTransfer = { types: [PRO_TAB_DRAG_MIME], getData: () => 'cal', dropEffect: '' };
|
||||
fireEvent.drop(strip, { dataTransfer });
|
||||
expect(seen).toHaveBeenCalled();
|
||||
expect(useProTabStore.getState().tabs.find((t) => t.id === 'cal')?.paneId).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { atLeast, useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||
import { OrgAnnouncement } from "@/components/org/org-announcement";
|
||||
import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
type BodyDropTarget,
|
||||
type MailboxDragPayload,
|
||||
} from "@/components/pro/pro-shell-drop";
|
||||
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProSplitSide } from "@/stores/pro-tab-store";
|
||||
import { handOffComposeTab, useProTabStore, type ProTab, type ProTabKind, type ProPaneId, type ProSplitSide } from "@/stores/pro-tab-store";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { onOpenEmail, proEmailTabOpener } from "@/lib/plugin-sandbox/open-email";
|
||||
@@ -48,6 +49,8 @@ import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
|
||||
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
|
||||
import { ProFolderTabBody } from "@/components/pro/pro-folder-tab-body";
|
||||
|
||||
const DESKTOP_MIN_WIDTH = 1024;
|
||||
|
||||
// Each surface renders the same component the standard routes do. Deep links
|
||||
// reach them through the handoff ProInterfaceRedirect parks (#733), not as
|
||||
// props - Pro tabs have no route of their own.
|
||||
@@ -193,6 +196,7 @@ export default function ProHome() {
|
||||
const t = useTranslations();
|
||||
const { label: skeletonLabel } = useSkeletonLabels();
|
||||
const { isMobile, isTablet, isDesktop } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(
|
||||
() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client
|
||||
@@ -266,9 +270,21 @@ export default function ProHome() {
|
||||
const surface = kind === 'calendar' || kind === 'contacts' || kind === 'files' || kind === 'settings'
|
||||
? `/${kind}`
|
||||
: '/';
|
||||
window.location.replace(`${getPathPrefix()}${surface}`);
|
||||
if (!proInterface) {
|
||||
window.location.replace(`${getPathPrefix()}${surface}`);
|
||||
return;
|
||||
}
|
||||
// The flags come from a store that a standard page may have left behind
|
||||
// from before the window widened: arriving here from there is no reason
|
||||
// to go straight back. The window itself says.
|
||||
if (window.matchMedia(atLeast(DESKTOP_MIN_WIDTH)).matches) return;
|
||||
// A narrower window (snapped to half a screen, say) is no reason to
|
||||
// reload: compose tabs live in memory, and they come back when the window
|
||||
// widens again. The one in front goes along to the standard composer.
|
||||
if (kind === 'compose' && focusedId) handOffComposeTab(focusedId);
|
||||
router.replace(surface);
|
||||
}
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface]);
|
||||
}, [initialCheckDone, isMobile, isTablet, proInterface, router]);
|
||||
|
||||
const mainTabs = useMemo(() => tabs.filter((t) => t.paneId === 'main'), [tabs]);
|
||||
const splitTabs = useMemo(() => tabs.filter((t) => t.paneId === 'split'), [tabs]);
|
||||
@@ -417,8 +433,10 @@ export default function ProHome() {
|
||||
if (!kind) return;
|
||||
const target = resolveTargetForEvent(e);
|
||||
setDraggedTabId(null);
|
||||
if (!target) return;
|
||||
// Also with nowhere to put it: a field under the pointer would take the text
|
||||
// a message row carries.
|
||||
e.preventDefault();
|
||||
if (!target) return;
|
||||
|
||||
if (kind === 'tab') {
|
||||
const draggedId = e.dataTransfer.getData(PRO_TAB_DRAG_MIME);
|
||||
@@ -451,6 +469,27 @@ export default function ProHome() {
|
||||
}
|
||||
};
|
||||
|
||||
// A tab being dragged is the shell's business wherever it goes, except onto
|
||||
// a tab strip: no surface takes tabs, and many drop handlers (settings rows,
|
||||
// address books, calendar days) stop or claim whatever crosses them. So the
|
||||
// shell answers on the way down, before they see it. Messages and folders
|
||||
// stay on the way up: a folder row in a pane is a real target for those.
|
||||
const isTabDragOffStrip = (e: DragEvent<HTMLDivElement>) =>
|
||||
dragKindFromTypes(e.dataTransfer.types) === 'tab'
|
||||
&& !(e.target instanceof Element && e.target.closest('[data-pane-strip]'));
|
||||
|
||||
const handleBodyDragOverCapture = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isTabDragOffStrip(e)) return;
|
||||
e.stopPropagation();
|
||||
handleBodyDragOver(e);
|
||||
};
|
||||
|
||||
const handleBodyDropCapture = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isTabDragOffStrip(e)) return;
|
||||
e.stopPropagation();
|
||||
handleBodyDrop(e);
|
||||
};
|
||||
|
||||
// Until the session is up: the same skeleton the server drew, so hydration
|
||||
// matches and nothing moves. Below desktop width the effect above is about
|
||||
// to send the visitor to the standard page; keep the skeleton up meanwhile
|
||||
@@ -516,7 +555,7 @@ export default function ProHome() {
|
||||
{/* Leftmost Navigation Rail - identical to the standard layout */}
|
||||
<div
|
||||
className="w-14 bg-secondary flex flex-col flex-shrink-0"
|
||||
style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
style={{ borderInlineEnd: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
>
|
||||
<NavigationRail
|
||||
collapsed
|
||||
@@ -544,8 +583,12 @@ export default function ProHome() {
|
||||
)}
|
||||
|
||||
{!inlineApp && (
|
||||
// The split's side and the drop zones are physical (left, right),
|
||||
// so the row must not flip in RTL: a drop on the right went left.
|
||||
<div
|
||||
className="relative flex flex-row flex-1 overflow-hidden min-w-0"
|
||||
className="relative flex flex-row rtl:flex-row-reverse flex-1 overflow-hidden min-w-0"
|
||||
onDragOverCapture={handleBodyDragOverCapture}
|
||||
onDropCapture={handleBodyDropCapture}
|
||||
onDragOver={handleBodyDragOver}
|
||||
onDragLeave={handleBodyDragLeave}
|
||||
onDrop={handleBodyDrop}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] }));
|
||||
let fail = false;
|
||||
let failLoad = false;
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (!init?.method && failLoad) return new Response('{}', { status: 500 });
|
||||
if (init?.method === 'POST' && fail) {
|
||||
return new Response(JSON.stringify({ error: 'not a URL' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
@@ -26,6 +28,7 @@ import { TelemetryPage } from '../_pages/telemetry';
|
||||
|
||||
afterEach(() => {
|
||||
fail = false;
|
||||
failLoad = false;
|
||||
toast.success.mockClear();
|
||||
toast.error.mockClear();
|
||||
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
|
||||
@@ -38,6 +41,18 @@ async function saveEndpoint(value: string) {
|
||||
}
|
||||
|
||||
describe('admin feedback', () => {
|
||||
// A failed load left the page on its loading skeleton for good.
|
||||
it('says a page could not load and offers to try again', async () => {
|
||||
failLoad = true;
|
||||
render(<TelemetryPage />);
|
||||
const retry = await screen.findByRole('button', { name: 'Retry' });
|
||||
expect(screen.getByText('Could not load the usage stats')).toBeTruthy();
|
||||
|
||||
failLoad = false;
|
||||
fireEvent.click(retry);
|
||||
expect(await screen.findByLabelText('Collector URL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says a save worked in a success toast, with no banner', async () => {
|
||||
await saveEndpoint('https://collector.example');
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Telemetry settings saved.'));
|
||||
|
||||
@@ -37,6 +37,22 @@ describe('admin branding image fields', () => {
|
||||
expect(wildcardFavicon.getAttribute('title')).toBe('Enter URL');
|
||||
});
|
||||
|
||||
// onError hides the image; while a URL is being typed each partial URL fails,
|
||||
// and the hidden element used to stay hidden for the finished URL too.
|
||||
it('brings the preview back after a URL that failed to load', async () => {
|
||||
const { container } = render(<BrandingPage />);
|
||||
await screen.findByRole('button', { name: 'mail.example.com' });
|
||||
const field = screen.getByRole('textbox', { name: 'App logo (dark mode)' });
|
||||
const preview = () => container.querySelector('img[alt="App logo (dark mode)"]') as HTMLImageElement;
|
||||
|
||||
fireEvent.change(field, { target: { value: '/branding/lo' } });
|
||||
fireEvent.error(preview());
|
||||
expect(preview().style.display).toBe('none');
|
||||
|
||||
fireEvent.change(field, { target: { value: '/branding/logo.svg' } });
|
||||
expect(preview().style.display).toBe('');
|
||||
});
|
||||
|
||||
// R9: the result in the past tense, without "successfully".
|
||||
it('reports an upload without "successfully"', async () => {
|
||||
const { container } = render(<BrandingPage />);
|
||||
|
||||
@@ -59,8 +59,13 @@ export function AuthPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (!res.ok) throw new Error();
|
||||
setConfig(await res.json());
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not load the settings' });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -209,7 +214,7 @@ export function AuthPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="mt-4 space-y-4 [overflow-wrap:anywhere]">
|
||||
<div>
|
||||
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
|
||||
Webmail origin
|
||||
|
||||
@@ -117,8 +117,13 @@ export function BrandingPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (!res.ok) throw new Error();
|
||||
setConfig(await res.json());
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not load the settings' });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -407,6 +412,7 @@ export function BrandingPage() {
|
||||
<ImageItem
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
previewOn={field.key.endsWith('DarkUrl') ? 'dark' : field.key.endsWith('LightUrl') ? 'light' : undefined}
|
||||
accept={field.accept}
|
||||
value={currentValue(field.key)}
|
||||
badge={overrideBadge(field.key)}
|
||||
@@ -590,6 +596,8 @@ export function BrandingPage() {
|
||||
|
||||
interface ImageItemProps {
|
||||
label: string;
|
||||
/** Shows the preview on the surface the logo is meant for, whatever the panel's own mode. */
|
||||
previewOn?: 'light' | 'dark';
|
||||
accept: string;
|
||||
value: string;
|
||||
badge: ReactNode;
|
||||
@@ -605,7 +613,7 @@ interface ImageItemProps {
|
||||
}
|
||||
|
||||
/** One image slot: a URL field, an upload button, and a preview of what is set. */
|
||||
function ImageItem({ label, accept, value, badge, placeholder, uploading, uploadDisabled, uploaded, overridden, onChange, onUpload, onDeleteUpload, onRevert }: ImageItemProps) {
|
||||
function ImageItem({ label, previewOn, accept, value, badge, placeholder, uploading, uploadDisabled, uploaded, overridden, onChange, onUpload, onDeleteUpload, onRevert }: ImageItemProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<SettingItem label={label}>
|
||||
@@ -662,8 +670,13 @@ function ImageItem({ label, accept, value, badge, placeholder, uploading, upload
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
|
||||
<div className={cn(
|
||||
'h-8 w-auto rounded flex items-center justify-center px-2',
|
||||
previewOn === 'dark' ? 'bg-neutral-900' : previewOn === 'light' ? 'border border-border bg-white' : 'bg-muted',
|
||||
)}>
|
||||
{/* The key: a URL that failed while it was being typed hides the image, and the next one must show again. */}
|
||||
<img
|
||||
key={value}
|
||||
src={withBasePath(value)}
|
||||
alt={label}
|
||||
className="max-h-6 max-w-[200px] object-contain"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { AdminList, AdminListRow } from '@/components/admin/list';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { formatAuditDetail } from '@/lib/admin/audit-format';
|
||||
import { adminPageDef, adminPath } from '@/lib/admin/admin-nav';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus } from '@/lib/version-check/types';
|
||||
@@ -232,7 +233,7 @@ export function DashboardPage() {
|
||||
<AdminListRow
|
||||
key={i}
|
||||
title={<code className="font-mono text-xs">{entry.action}</code>}
|
||||
description={formatDetail(entry.detail) || undefined}
|
||||
description={formatAuditDetail(entry.detail) || undefined}
|
||||
meta={<>{entry.ip} · {new Date(entry.ts).toLocaleString()}</>}
|
||||
/>
|
||||
))}
|
||||
@@ -286,11 +287,3 @@ function StatTile({ label, value, sub, subTone = 'muted', href }: StatTileProps)
|
||||
function OnOff({ on }: { on: boolean }) {
|
||||
return <Badge tone={on ? 'success' : 'muted'}>{on ? 'On' : 'Off'}</Badge>;
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '';
|
||||
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
|
||||
return JSON.stringify(detail).slice(0, 80);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { formatAuditDetail } from '@/lib/admin/audit-format';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -20,21 +22,28 @@ export function LogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const setMessage = useAdminFeedback();
|
||||
const limit = 50;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (actionFilter) params.set('action', actionFilter);
|
||||
|
||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||
if (res.ok) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setLoadFailed(true);
|
||||
setMessage({ type: 'error', text: 'Could not load the audit log' });
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, actionFilter]);
|
||||
}, [page, actionFilter, setMessage]);
|
||||
|
||||
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||
|
||||
@@ -63,6 +72,14 @@ export function LogsPage() {
|
||||
<option value="config.update">Config Update</option>
|
||||
<option value="config.revert">Config Revert</option>
|
||||
<option value="policy.update">Policy Update</option>
|
||||
<option value="plugin.install">Plugin Install</option>
|
||||
<option value="plugin.update">Plugin Update</option>
|
||||
<option value="plugin.rollback">Plugin Rollback</option>
|
||||
<option value="plugin.delete">Plugin Removal</option>
|
||||
<option value="theme.install">Theme Install</option>
|
||||
<option value="theme.update">Theme Update</option>
|
||||
<option value="theme.rollback">Theme Rollback</option>
|
||||
<option value="theme.delete">Theme Removal</option>
|
||||
<option value="device.wipe.request">Device Wipe Requested</option>
|
||||
<option value="device.wipe.confirm">Device Wipe Confirmed</option>
|
||||
<option value="device.wipe.cancel">Device Wipe Cancelled</option>
|
||||
@@ -83,12 +100,12 @@ export function LogsPage() {
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No entries found"
|
||||
hint={actionFilter ? 'Nothing matches this action filter.' : 'Admin actions show up here as they happen.'}
|
||||
title={loadFailed ? 'Could not load the audit log' : 'No entries found'}
|
||||
hint={loadFailed ? undefined : actionFilter ? 'Nothing matches this action filter.' : 'Admin actions show up here as they happen.'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="sm:hidden space-y-2">
|
||||
<div className="md:hidden space-y-2">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -98,7 +115,7 @@ export function LogsPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-foreground break-words">
|
||||
{formatDetail(entry.detail)}
|
||||
{formatAuditDetail(entry.detail) || '-'}
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
@@ -107,7 +124,7 @@ export function LogsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block rounded-lg border border-border overflow-x-auto">
|
||||
<div className="hidden md:block rounded-lg border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
@@ -126,11 +143,12 @@ export function LogsPage() {
|
||||
<td className="px-4 py-2">
|
||||
<Badge className="font-mono text-xs">{entry.action}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
|
||||
{formatDetail(entry.detail)}
|
||||
<td className="w-full max-w-0 truncate px-4 py-2 text-xs text-foreground" title={formatAuditDetail(entry.detail)}>
|
||||
{formatAuditDetail(entry.detail) || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip}
|
||||
{/* A fixed width, so a full IPv6 address wraps instead of pushing the table wide. */}
|
||||
<div className="w-28 break-all">{entry.ip}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -158,14 +176,3 @@ export function LogsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||
if (detail.changes && Array.isArray(detail.changes)) {
|
||||
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
|
||||
}
|
||||
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
|
||||
return JSON.stringify(detail).slice(0, 100);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
Download,
|
||||
@@ -40,6 +40,7 @@ import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { installVersion } from './marketplace';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { scrollBehavior } from '@/lib/reduced-motion';
|
||||
import { ChangelogBadges, ChangelogEntries, ChangelogNotes, type ChangelogFlags } from '@/components/admin/changelog';
|
||||
import { versionsBetween } from '@/lib/admin/changelog-range';
|
||||
|
||||
@@ -136,6 +137,7 @@ const smallButtonClass = 'h-8 gap-1 text-xs';
|
||||
|
||||
export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [data, setData] = useState<PreviewData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -184,6 +186,9 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
setSelected(version);
|
||||
setMessage(null);
|
||||
// What a pick changes is at the top: the notices and the install button.
|
||||
// From a row of the versions table it would all happen out of sight.
|
||||
pageRef.current?.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
|
||||
}
|
||||
|
||||
async function handleInstall(version: string) {
|
||||
@@ -342,7 +347,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
const updateAvailable = data.installed && !!ext.latest && !!data.installedVersion && compareVersions(ext.latest.version, data.installedVersion) > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={pageRef}>
|
||||
<AdminPageHeader
|
||||
back={BACK}
|
||||
leading={(
|
||||
@@ -559,8 +564,9 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
title="Versions"
|
||||
description="Every published version. Versions this server still has a copy of can be put back without the directory."
|
||||
>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
{/* relative: the header's sr-only text is absolute, and only a positioned scroller clips it. Left free, it widens a phone's layout viewport and every dialog goes with it. */}
|
||||
<div className="relative overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full min-w-[40rem] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className={thClass}>Version</th>
|
||||
|
||||
@@ -300,7 +300,7 @@ export function MarketplacePage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className={pillGroupClass}>
|
||||
{([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => (
|
||||
<button key={v} type="button" onClick={() => setView(v)} aria-pressed={view === v} className={pillClass(view === v)}>
|
||||
@@ -308,7 +308,7 @@ export function MarketplacePage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1 sm:min-w-56">
|
||||
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -594,6 +594,7 @@ function InstalledView({
|
||||
href={fromMarketplace ? detailHref(entry.id) : undefined}
|
||||
icon={entry.kind === 'plugin' ? <Puzzle /> : <SwatchBook />}
|
||||
title={entry.name}
|
||||
wideControl
|
||||
titleExtra={(
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">v{entry.version}</span>
|
||||
|
||||
@@ -48,11 +48,16 @@ export function MonitoringPage() {
|
||||
const setMessage = useAdminFeedback();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [configRes, stateRes] = await Promise.all([apiFetch('/api/admin/config'), apiFetch('/api/admin/monitoring')]);
|
||||
if (configRes.ok) setConfig(await configRes.json());
|
||||
if (stateRes.ok) setState(await stateRes.json());
|
||||
try {
|
||||
const [configRes, stateRes] = await Promise.all([apiFetch('/api/admin/config'), apiFetch('/api/admin/monitoring')]);
|
||||
if (configRes.ok) setConfig(await configRes.json());
|
||||
if (stateRes.ok) setState(await stateRes.json());
|
||||
if (!configRes.ok || !stateRes.ok) throw new Error();
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not load the settings' });
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [setMessage]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) {
|
||||
{rows.map(([path, source]) => (
|
||||
<tr key={path} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{path}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{formatValue(valueAt(resolved.policy as unknown as Record<string, unknown>, path))}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all"><div className="min-w-[5ch]">{formatValue(valueAt(resolved.policy as unknown as Record<string, unknown>, path))}</div></td>
|
||||
<td className="px-3 py-1.5 text-xs">{sourceLabel(source, draft.scopes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function PluginApprovalsPanel() {
|
||||
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
|
||||
</div>
|
||||
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground [overflow-wrap:anywhere]">
|
||||
<code className="font-mono">{entry.pluginId}</code> · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
|
||||
</div>
|
||||
{perms.length > 0 && (
|
||||
|
||||
@@ -27,6 +27,7 @@ export function PluginsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const setMessage = useAdminFeedback();
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
// On/off per plugin waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -40,11 +41,15 @@ export function PluginsPage() {
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
if (!res.ok) throw new Error();
|
||||
setPlugins(await res.json());
|
||||
} catch {
|
||||
setLoadFailed(true);
|
||||
setMessage({ type: 'error', text: 'Could not load the plugins' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setMessage]);
|
||||
|
||||
const fetchPolicy = useCallback(async () => {
|
||||
try {
|
||||
@@ -211,8 +216,8 @@ export function PluginsPage() {
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title="No plugins installed"
|
||||
hint="Install one from the marketplace or upload a plugin ZIP."
|
||||
title={loadFailed ? 'Could not load the plugins' : 'No plugins installed'}
|
||||
hint={loadFailed ? undefined : 'Install one from the marketplace or upload a plugin ZIP.'}
|
||||
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
|
||||
/>
|
||||
) : (
|
||||
@@ -245,7 +250,7 @@ export function PluginsPage() {
|
||||
{plugin.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
|
||||
{plugin.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
|
||||
{risky.length > 0 && (
|
||||
<Badge tone="warning" icon={<ShieldAlert className="h-3 w-3" />} title={risky.join(', ')}>
|
||||
<Badge tone="warning" icon={<ShieldAlert className="h-3 w-3" />} title={risky.join(', ')} className="whitespace-normal">
|
||||
{risky.length} high-risk permission{risky.length === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -36,9 +36,12 @@ export function SettingsPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (!res.ok) throw new Error();
|
||||
setConfig(await res.json());
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Could not load the settings' });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Loader2, Send, ExternalLink } from '@/components/icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -146,6 +147,18 @@ export function TelemetryPage() {
|
||||
|
||||
const header = <AdminPageHeader title={PAGE.label} description={PAGE.description} />;
|
||||
|
||||
if (!loading && !status) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<EmptyState
|
||||
title="Could not load the usage stats"
|
||||
action={<Button type="button" size="sm" onClick={() => void refresh()}>Retry</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !status) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -43,6 +43,7 @@ export function ThemesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const setMessage = useAdminFeedback();
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
// On/off per theme waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -55,11 +56,15 @@ export function ThemesPage() {
|
||||
const fetchThemes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/themes');
|
||||
if (res.ok) setThemes(await res.json());
|
||||
if (!res.ok) throw new Error();
|
||||
setThemes(await res.json());
|
||||
} catch {
|
||||
setLoadFailed(true);
|
||||
setMessage({ type: 'error', text: 'Could not load the themes' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [setMessage]);
|
||||
|
||||
const fetchPolicy = useCallback(async () => {
|
||||
try {
|
||||
@@ -288,8 +293,8 @@ export function ThemesPage() {
|
||||
) : themes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SwatchBook}
|
||||
title="No themes installed"
|
||||
hint="Install one from the marketplace or upload a theme ZIP."
|
||||
title={loadFailed ? 'Could not load the themes' : 'No themes installed'}
|
||||
hint={loadFailed ? undefined : 'Install one from the marketplace or upload a theme ZIP.'}
|
||||
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { AdminBodySkeleton } from '@/components/boot-skeleton/admin-skeleton';
|
||||
import { Badge, type BadgeTone } from '@/components/admin/badge';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
@@ -114,6 +115,18 @@ export function VersionPage() {
|
||||
/>
|
||||
);
|
||||
|
||||
if (!loading && !data) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<EmptyState
|
||||
title="Could not load the update status"
|
||||
action={<Button type="button" size="sm" onClick={() => void refresh()}>Retry</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -80,13 +80,18 @@
|
||||
forced colours. Kit components carry no focus ring of their own. Only
|
||||
containers focused from script (tabindex="-1" without a control role) skip
|
||||
it. */
|
||||
:is(.bk, .bk *):focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, [role="button"], [role="menuitem"], [role="option"], [role="tab"], [role="row"], [role="gridcell"], [role="checkbox"], [role="switch"], [role="radio"])) {
|
||||
:is(.bk, .bk *):focus-visible:where(:not([tabindex="-1"]:not(a[href], button, input, select, textarea, [role="button"], [role="menuitem"], [role="option"], [role="tab"], [role="row"], [role="gridcell"], [role="checkbox"], [role="switch"], [role="radio"]))) {
|
||||
outline: 2px solid var(--color-ring) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
:is(.bk [role="option"], .bk [role="row"], .bk [role="tab"], .bk .focus-inset, .bk.focus-inset):focus-visible {
|
||||
outline-offset: -2px !important;
|
||||
}
|
||||
/* A field usually spans the whole frame, and the frame clips what leaves it:
|
||||
drawn outside, its ring only shows above and below it. */
|
||||
:is(input.bk, textarea.bk, select.bk, .bk input, .bk textarea, .bk select):focus-visible {
|
||||
outline-offset: -2px !important;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:is(.bk, .bk *):focus-visible {
|
||||
outline-color: Highlight !important;
|
||||
|
||||
@@ -130,8 +130,9 @@
|
||||
}
|
||||
/* Inside a scroll container a positive offset is clipped; draw it inside.
|
||||
`focus-inset` does the same for any control that sits flush against the
|
||||
edge of a clipping container. */
|
||||
:is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible {
|
||||
edge of a clipping container. The selector is padded to outweigh the two
|
||||
rules above, whose :not() lists count towards their specificity. */
|
||||
html:root :is([role="option"], [role="row"], [role="treeitem"], [role="gridcell"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="tab"], .focus-inset):focus-visible:focus-visible:focus-visible:focus-visible {
|
||||
outline-offset: calc(var(--focus-ring-width) * -1) !important;
|
||||
}
|
||||
/* The focused element scrolls clear of sticky headers and the bottom bar,
|
||||
|
||||
+45
-1
@@ -1304,6 +1304,39 @@ html[data-window-controls-left] [data-window-rail] {
|
||||
inset-inline-end: 3.25rem;
|
||||
}
|
||||
|
||||
/* Large text in a narrow window leaves the field little width, less than these
|
||||
paddings add up to: the input then stuck out under the bell, with the filter
|
||||
button on top of the magnifier. Here the text gets what the magnifier had. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 16rem) {
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] {
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > svg {
|
||||
display: none;
|
||||
}
|
||||
/* Typed text stops at the clear button, and the / key cap makes way for the
|
||||
placeholder. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:not(:placeholder-shown) {
|
||||
padding-inline-end: 5rem;
|
||||
}
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > div kbd {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* No room left for both: the text goes first. */
|
||||
@container (max-width: 9rem) {
|
||||
[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:not(:placeholder-shown) {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > div {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The magnifier sits on the field's baseline grid, one 48px hit area in. */
|
||||
[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > div > svg {
|
||||
inset-inline-start: 1rem;
|
||||
@@ -1460,10 +1493,21 @@ html[data-window-controls-left] [data-window-rail] {
|
||||
/* The account popover: a wide card with a very round corner, rather
|
||||
than the narrow menu the rail and the sidebar switchers drop. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-account-popover] {
|
||||
width: var(--mv-popover-w);
|
||||
width: min(var(--mv-popover-w), calc(100vw - 2rem));
|
||||
border-radius: var(--mv-popover-radius);
|
||||
}
|
||||
|
||||
/* The compose window docks beside the side panel, not over it: over it, the
|
||||
window hides the panel's Close button and the rail's own buttons. The rail is
|
||||
3.5rem wide, which grows with the text size, an open panel a fixed 300px plus
|
||||
8px of margin, which doesn't. */
|
||||
[data-interface-layout="mountain-view"]:has([data-mv-side-rail]) {
|
||||
--mv-compose-end: 4.5rem;
|
||||
}
|
||||
[data-interface-layout="mountain-view"]:has([data-mv-side-panel], [data-plugin-side-panel]) {
|
||||
--mv-compose-end: calc(3.5rem + 308px + 1rem);
|
||||
}
|
||||
|
||||
/* The advanced filters drop from the search field with a small gap and take
|
||||
its corner, so the two read as one control. Position and width come from
|
||||
the field's own box, measured by FilterPanelHost. */
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/navigation', () => ({ usePathname: () => '/admin/settings' }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/settings/about-data-settings', () => ({ AboutFooter: () => null }));
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(() => Promise.resolve({ ok: false })),
|
||||
getPathPrefix: () => '',
|
||||
withBasePath: (href: string) => href,
|
||||
}));
|
||||
|
||||
import { AdminShell } from '../admin-shell';
|
||||
|
||||
// Turning a tablet round crosses the breakpoint between the shell's two
|
||||
// layouts. The page used to be remounted there, so what the admin had typed
|
||||
// and not yet saved was gone without a word.
|
||||
describe('AdminShell across the desktop breakpoint', () => {
|
||||
function mockWidth(initial: boolean) {
|
||||
let desktop = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
get matches() { return desktop && query === '(width >= 64rem)'; },
|
||||
media: query,
|
||||
addEventListener: (_: string, cb: () => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
return (next: boolean) => act(() => {
|
||||
desktop = next;
|
||||
listeners.forEach((cb) => cb());
|
||||
});
|
||||
}
|
||||
|
||||
function Page({ onMount }: { onMount: () => void }) {
|
||||
const [value, setValue] = useState('saved');
|
||||
useEffect(onMount, [onMount]);
|
||||
return <input aria-label="field" value={value} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
|
||||
it('keeps the page mounted, with its edits, in both directions', () => {
|
||||
Element.prototype.scrollTo = () => {};
|
||||
const setDesktop = mockWidth(true);
|
||||
const onMount = vi.fn();
|
||||
render(
|
||||
<AdminShell authenticated isStalwartAutoLogin={false} configReadOnly={false} onLogout={() => {}}>
|
||||
<Page onMount={onMount} />
|
||||
</AdminShell>,
|
||||
);
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'field' }), { target: { value: 'edited' } });
|
||||
|
||||
setDesktop(false);
|
||||
expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
|
||||
setDesktop(true);
|
||||
expect(screen.getByRole('textbox', { name: 'field' })).toHaveValue('edited');
|
||||
expect(onMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// On a laptop the list runs past its fold: a page opened from a link used
|
||||
// to leave its own entry out of sight.
|
||||
it('brings the open page into view in the sidebar', () => {
|
||||
Element.prototype.scrollTo = () => {};
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
mockWidth(true);
|
||||
render(
|
||||
<AdminShell authenticated isStalwartAutoLogin={false} configReadOnly={false} onLogout={() => {}}>
|
||||
<p>page</p>
|
||||
</AdminShell>,
|
||||
);
|
||||
expect(scrollIntoView.mock.contexts.some((el) => (el as Element).getAttribute('aria-current') === 'page')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, ChevronRight, KeyRound, LogOut, Search, Shield, X } from '@/components/icons';
|
||||
@@ -62,6 +63,16 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
const [pendingApprovals, setPendingApprovals] = useState(0);
|
||||
const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
// The two layouts are different trees, so a page drawn inside them would be
|
||||
// remounted when the window crosses the breakpoint (a tablet turned round)
|
||||
// and lose what the admin had typed. It lives in one element instead, which
|
||||
// moves into whichever layout is showing. The portal comes after the layout
|
||||
// so the element is in the document before the page's effects run.
|
||||
const [pageHost] = useState(() => (typeof document === 'undefined' ? null : document.createElement('div')));
|
||||
const pageSlot = useCallback((slot: HTMLDivElement | null) => {
|
||||
if (slot && pageHost) slot.appendChild(pageHost);
|
||||
}, [pageHost]);
|
||||
|
||||
const onChangePassword = pathname === '/admin/change-password';
|
||||
const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null);
|
||||
@@ -95,6 +106,12 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [scrollKey]);
|
||||
|
||||
// A page opened from a link or a reload can be far down the list, under its
|
||||
// fold on a laptop: bring its entry into view.
|
||||
useEffect(() => {
|
||||
navRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [activePage, isDesktop, hydrated]);
|
||||
|
||||
// A section picked from the search scrolls into view once its page has
|
||||
// rendered it; pages fetch first, so retry for a moment.
|
||||
useEffect(() => {
|
||||
@@ -257,8 +274,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
);
|
||||
}
|
||||
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
const page = pageHost && (isDesktop || !showIndex) ? createPortal(children, pageHost) : null;
|
||||
|
||||
if (!isDesktop) {
|
||||
const showIndex = location.page === null && !onChangePassword;
|
||||
const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin';
|
||||
const backHref = location.detail ? adminPath(location.page) : '/admin';
|
||||
return (
|
||||
@@ -346,7 +365,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
) : (
|
||||
<div className="p-4 pb-6">
|
||||
{readOnlyBanner}
|
||||
{children}
|
||||
<div ref={pageSlot} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -355,6 +374,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
|
||||
<NavigationRail orientation="horizontal" adminActive />
|
||||
</div>
|
||||
{page}
|
||||
</AdminShellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -383,7 +403,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-3 pb-1 pt-1">{searchBox}</div>
|
||||
<nav className="space-y-0.5 px-2" aria-label={tShell('admin_pages')}>
|
||||
<nav ref={navRef} className="space-y-0.5 px-2" aria-label={tShell('admin_pages')}>
|
||||
{groups.length === 0 && noResults}
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
@@ -449,12 +469,13 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
{readOnlyBanner}
|
||||
{children}
|
||||
<div ref={pageSlot} />
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setSaveBarHost} className="shrink-0" />
|
||||
</main>
|
||||
</div>
|
||||
{page}
|
||||
</AdminShellContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,17 +31,20 @@ interface AdminListRowProps {
|
||||
meta?: ReactNode;
|
||||
/** Controls on the end side; they sit above the row link. */
|
||||
control?: ReactNode;
|
||||
/** The control is wider than a switch: on a phone it gets its own line under the text. */
|
||||
wideControl?: boolean;
|
||||
className?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function AdminListRow({ href, icon, title, titleExtra, description, meta, control, className, ...rest }: AdminListRowProps) {
|
||||
export function AdminListRow({ href, icon, title, titleExtra, description, meta, control, wideControl, className, ...rest }: AdminListRowProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={rest['data-testid']}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-4 py-3 transition-colors',
|
||||
href && 'hover:bg-muted/40',
|
||||
wideControl && 'max-sm:flex-wrap',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +53,7 @@ export function AdminListRow({ href, icon, title, titleExtra, description, meta,
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn('min-w-0 flex-1', wideControl && 'max-sm:basis-0')}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{href ? (
|
||||
<AdminLink
|
||||
@@ -67,7 +70,11 @@ export function AdminListRow({ href, icon, title, titleExtra, description, meta,
|
||||
{description && <p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{description}</p>}
|
||||
{meta && <div className="mt-0.5 text-xs text-muted-foreground">{meta}</div>}
|
||||
</div>
|
||||
{control && <div className="relative z-10 flex shrink-0 items-center gap-2">{control}</div>}
|
||||
{control && (
|
||||
<div className={cn('relative z-10 flex shrink-0 items-center gap-2', wideControl && 'max-sm:order-last max-sm:w-full max-sm:flex-wrap max-sm:ps-12')}>
|
||||
{control}
|
||||
</div>
|
||||
)}
|
||||
{href && <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,22 +50,24 @@ export function ScreenshotGallery({ images, name }: { images: GalleryImage[]; na
|
||||
description={open !== null && images[open].altText ? images[open].altText : undefined}
|
||||
size="lg"
|
||||
className="max-w-[min(96vw,1400px)]"
|
||||
bodyClassName="max-sm:px-3"
|
||||
testId="screenshot-gallery-dialog"
|
||||
>
|
||||
{open !== null && (
|
||||
<div className="mt-4 flex items-center gap-2" onKeyDown={onKeyDown}>
|
||||
// On a phone the arrows go under the image: beside it they leave it smaller than the thumbnail.
|
||||
<div className="mt-4 grid grid-cols-2 items-center gap-2 sm:flex" onKeyDown={onKeyDown}>
|
||||
{count > 1 && (
|
||||
<IconButton label="Previous screenshot" onClick={() => step(-1)} className="shrink-0">
|
||||
<IconButton label="Previous screenshot" onClick={() => step(-1)} className="row-start-2 shrink-0 justify-self-start">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<img
|
||||
src={images[open].url}
|
||||
alt={alt(open)}
|
||||
className="block min-w-0 flex-1 max-h-[75vh] object-contain rounded-md border border-border bg-muted"
|
||||
className="col-span-2 block w-full min-w-0 max-h-[min(75dvh,calc(100dvh-8rem))] object-contain rounded-md border border-border bg-muted sm:w-auto sm:flex-1"
|
||||
/>
|
||||
{count > 1 && (
|
||||
<IconButton label="Next screenshot" onClick={() => step(1)} className="shrink-0">
|
||||
<IconButton label="Next screenshot" onClick={() => step(1)} className="row-start-2 shrink-0 justify-self-end">
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, act, cleanup } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// Rows change height with the window. The week at the top has to stay there:
|
||||
// with only the scroll offset kept, the view slid to other weeks.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CalendarMonthView on a resized window', () => {
|
||||
it('keeps the same week at the top when the rows change height', () => {
|
||||
const observers: Array<() => void> = [];
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(cb: () => void) { observers.push(cb); }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
});
|
||||
const notify = () => observers.forEach((cb) => cb());
|
||||
|
||||
let clientHeight = 600;
|
||||
let rowHeight = 100;
|
||||
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => clientHeight);
|
||||
const rows = () => Array.from(document.querySelectorAll<HTMLElement>('[data-week]'));
|
||||
const scroller = () => document.querySelector<HTMLElement>('.overflow-y-auto')!;
|
||||
// A small layout engine: rows stacked at their height, moved by scrollTop.
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const index = rows().indexOf(this);
|
||||
const top = index < 0 ? 0 : index * rowHeight - scroller().scrollTop;
|
||||
const height = index < 0 ? clientHeight : rowHeight;
|
||||
return { top, bottom: top + height, height, left: 0, right: 100, width: 100, x: 0, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
|
||||
render(
|
||||
<CalendarMonthView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="month:2026-09-09"
|
||||
rangeStart={new Date(2026, 7, 3)}
|
||||
rangeEnd={new Date(2026, 10, 1)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[]}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const el = scroller();
|
||||
const third = rows()[2];
|
||||
act(() => notify()); // the first sighting of each row's height
|
||||
act(() => {
|
||||
el.scrollTop = 200;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
return new Promise<void>((resolve) => requestAnimationFrame(() => {
|
||||
act(() => {
|
||||
clientHeight = 1800;
|
||||
rowHeight = 300;
|
||||
notify();
|
||||
});
|
||||
expect(third.getBoundingClientRect().top).toBe(0);
|
||||
resolve();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (k: string) => k,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({ dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en', options).format(d) }),
|
||||
}));
|
||||
|
||||
const { CalendarMonthView, monthGridMetrics } = await import('../calendar-month-view');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
describe('monthGridMetrics', () => {
|
||||
it('keeps the 24px chip with a mouse at normal text size', () => {
|
||||
const m = monthGridMetrics(false, 1);
|
||||
expect(m.chipHeight).toBe(24);
|
||||
expect(m.overlayTop).toBe(30);
|
||||
expect(m.baseRowMinHeight).toBe(30 + 4 + 8 + 4 * 26);
|
||||
});
|
||||
|
||||
it('grows the rows and the day number strip with larger text', () => {
|
||||
const m = monthGridMetrics(false, 2);
|
||||
expect(m.chipHeight).toBe(50);
|
||||
expect(m.overlayTop).toBe(60);
|
||||
expect(m.baseRowMinHeight).toBe(60 + 4 + 8 + 4 * 52);
|
||||
});
|
||||
|
||||
it('does not shrink chips below the target size for smaller text', () => {
|
||||
expect(monthGridMetrics(false, 0.875).chipHeight).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarMonthView text size', () => {
|
||||
const original = useSettingsStore.getState().fontSize;
|
||||
afterEach(() => useSettingsStore.setState({ fontSize: original }));
|
||||
|
||||
const chipAt = (fontSize: 'medium' | 'huge') => {
|
||||
useSettingsStore.setState({ fontSize });
|
||||
render(createElement(CalendarMonthView, {
|
||||
focus: { date: new Date(2026, 9, 7), nonce: 0 },
|
||||
windowKey: 'month:2026-10-07',
|
||||
rangeStart: new Date(2026, 8, 28),
|
||||
rangeEnd: new Date(2026, 10, 1),
|
||||
selectedDate: new Date(2026, 9, 7),
|
||||
events: [{
|
||||
id: 'e1', uid: 'u1', title: 'Birthday', start: '2026-10-07T00:00:00', duration: 'P1D', showWithoutTime: true,
|
||||
calendarIds: { 'cal-1': true }, status: 'confirmed',
|
||||
} as unknown as CalendarEvent],
|
||||
calendars: [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[],
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
}));
|
||||
return screen.getByRole('button', { name: /Birthday/ }).parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
it('keeps the normal chip at the normal text size', () => {
|
||||
const chip = chipAt('medium');
|
||||
expect(chip.style.height).toBe('24px');
|
||||
expect(chip.style.top).toBe('30px');
|
||||
});
|
||||
|
||||
it('sizes the chips from the text size the user chose', () => {
|
||||
const chip = chipAt('huge');
|
||||
expect(chip.style.height).toBe('50px');
|
||||
expect(chip.style.top).toBe('60px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// A new event's dashed preview used to sit under the day's last entry. On a
|
||||
// full day that is below the row of the week, on top of the next week's date.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function events(count: number): CalendarEvent[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `e${i}`,
|
||||
'@type': 'Event',
|
||||
uid: `e${i}`,
|
||||
title: `Event ${i}`,
|
||||
start: `2026-09-09T${String(8 + i).padStart(2, '0')}:00:00`,
|
||||
duration: 'PT30M',
|
||||
showWithoutTime: false,
|
||||
calendarIds: { 'cal-1': true },
|
||||
})) as unknown as CalendarEvent[];
|
||||
}
|
||||
|
||||
function renderMonth(count: number) {
|
||||
return render(
|
||||
<CalendarMonthView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="month:2026-09-09"
|
||||
rangeStart={new Date(2026, 7, 31)}
|
||||
rangeEnd={new Date(2026, 9, 4)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={events(count)}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
pendingPreview={{ title: 'New event', start: new Date(2026, 8, 9, 15), end: new Date(2026, 8, 9, 16), allDay: false, calendarId: 'cal-1' }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CalendarMonthView pending event preview', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each([1, 3, 6, 12])('stays inside its own week with %i events on the day', (count) => {
|
||||
renderMonth(count);
|
||||
const preview = document.querySelector<HTMLElement>('[data-pending-preview]');
|
||||
expect(preview).not.toBeNull();
|
||||
const slot = preview!.parentElement!;
|
||||
const week = preview!.closest<HTMLElement>('[data-week]')!;
|
||||
const bottom = parseFloat(slot.style.top) + parseFloat(slot.style.height);
|
||||
expect(bottom).toBeLessThanOrEqual(parseFloat(week.style.minHeight));
|
||||
});
|
||||
});
|
||||
@@ -186,3 +186,20 @@ describe('CalendarWeekView all-day and tasks height cap', () => {
|
||||
expect(screen.getByRole('button', { name: /events.show_more|events.show_less/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarWeekView column width', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Rounded down, the columns left a few pixels at the edge, where next Monday showed through.
|
||||
it('fills the pane with exactly seven columns when the width does not divide evenly', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
|
||||
renderView();
|
||||
const columns = document.querySelector<HTMLElement>('[style*="grid-template-columns"]')!;
|
||||
const colWidth = parseFloat(/([\d.]+)px/.exec(columns.style.gridTemplateColumns)![1]);
|
||||
// 1000px less the 56px hour gutter
|
||||
expect(colWidth * 7).toBeCloseTo(944, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,16 @@ describe('EventCard', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// The block of a short event has room for its title line only; the count was cut in half.
|
||||
it.each([
|
||||
['PT30M', false],
|
||||
['PT45M', true],
|
||||
])('shows the participant count on a %s block: %s', (duration, shown) => {
|
||||
const event = { ...makeOccurrence('a', 'accepted'), duration } as CalendarEvent;
|
||||
render(<EventCard event={event} variant="block" currentUserEmails={me} />);
|
||||
expect(screen.queryByTitle('participants.count') !== null).toBe(shown);
|
||||
});
|
||||
|
||||
it.each(['chip', 'span', 'block'] as const)('strikes through an occurrence the user declined (%s)', (variant) => {
|
||||
render(<EventCard event={makeOccurrence('a', 'declined')} variant={variant} currentUserEmails={me} />);
|
||||
const card = screen.getByRole('button');
|
||||
|
||||
@@ -166,7 +166,7 @@ export function CalendarAgendaView({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto [overflow-anchor:none]"
|
||||
className="flex-1 overflow-y-auto [overflow-anchor:none] max-md:pb-14"
|
||||
ref={scrollContainerRef}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
@@ -220,7 +220,7 @@ export function CalendarAgendaView({
|
||||
</span>
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span className={cn(
|
||||
"block text-[0.6875rem] font-medium truncate first-letter:uppercase",
|
||||
"block text-[0.6875rem] font-medium break-words first-letter:uppercase",
|
||||
today ? "text-primary" : "text-muted-foreground",
|
||||
)}>
|
||||
{intlFormatter.dateTime(group.date, { month: "short", weekday: "short" })}
|
||||
@@ -261,10 +261,11 @@ export function CalendarAgendaView({
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex items-start gap-3 px-2 rounded-md hover:bg-muted/60 transition-colors text-start"
|
||||
className="w-full flex flex-wrap min-[30rem]:flex-nowrap items-start gap-x-3 gap-y-0.5 px-2 rounded-md hover:bg-muted/60 transition-colors text-start"
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
|
||||
{/* On a phone the time takes a line of its own: beside the date column it left the title two words. */}
|
||||
<span className="basis-full min-[30rem]:basis-auto min-[30rem]:w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
|
||||
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ export function CalendarDayView({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="flex min-h-0 min-w-0 flex-col flex-1 overflow-auto [overflow-anchor:none]"
|
||||
className="flex min-h-0 min-w-0 flex-col flex-1 overflow-auto [overflow-anchor:none] max-md:pb-20"
|
||||
onScroll={handleStripScroll}
|
||||
role="grid"
|
||||
aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
|
||||
@@ -14,11 +14,12 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { movedEventUpdates } from "@/lib/calendar-participants";
|
||||
import { useSettingsStore, type MonthTimedEventStyle } from "@/stores/settings-store";
|
||||
import { FONT_SIZE_SCALE } from "@/lib/accessibility-settings";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import { dayKey, mostVisibleMonthDay, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
@@ -61,8 +62,21 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
onMoveEvent?: MoveEventHandler;
|
||||
}
|
||||
|
||||
/** Fraction of the viewport height at which the "current month" is sampled. */
|
||||
const VISIBLE_MONTH_SAMPLE = 0.4;
|
||||
/**
|
||||
* The month grid's rows are positioned in pixels, so they follow the text size
|
||||
* by hand: at a fixed height large text was cut off and ran into the day number.
|
||||
*/
|
||||
export function monthGridMetrics(isMobile: boolean, textScale: number) {
|
||||
const scale = Math.max(1, textScale);
|
||||
const overlayTop = Math.round((isMobile ? 34 : 30) * scale);
|
||||
// 24px chips with a mouse (WCAG 2.5.8); touch screens show dots.
|
||||
const rowHeight = Math.round((isMobile ? 18 : 26) * scale);
|
||||
const chipHeight = rowHeight - 2;
|
||||
// A wide-screen week row fits at least three entries and the "N more"
|
||||
// link, so a short window (1280x720) does not cut every day to one.
|
||||
const baseRowMinHeight = isMobile ? 52 : overlayTop + 4 + 8 + 4 * rowHeight;
|
||||
return { overlayTop, rowHeight, chipHeight, baseRowMinHeight };
|
||||
}
|
||||
|
||||
export function CalendarMonthView({
|
||||
selectedDate,
|
||||
@@ -102,13 +116,8 @@ export function CalendarMonthView({
|
||||
// On mobile the month view collapses events to dots unless the user opted
|
||||
// into full entries via "Show time in month view" (#666).
|
||||
const showChips = !isMobile || showTimeInMonthView;
|
||||
const overlayTop = isMobile ? 34 : 30;
|
||||
// 24px chips with a mouse (WCAG 2.5.8); touch screens show dots.
|
||||
const rowHeight = isMobile ? 18 : 26;
|
||||
const chipHeight = rowHeight - 2;
|
||||
// A wide-screen week row fits at least three entries and the "N more"
|
||||
// link, so a short window (1280x720) does not cut every day to one.
|
||||
const baseRowMinHeight = isMobile ? 52 : overlayTop + 4 + 8 + 4 * rowHeight;
|
||||
const textScale = FONT_SIZE_SCALE[useSettingsStore((state) => state.fontSize)] ?? 1;
|
||||
const { overlayTop, rowHeight, chipHeight, baseRowMinHeight } = monthGridMetrics(Boolean(isMobile), textScale);
|
||||
const {
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
@@ -220,30 +229,42 @@ export function CalendarMonthView({
|
||||
const rowCapacity = Math.max(1, Math.floor((rowMinHeight - overlayTop - 4 - 8) / rowHeight));
|
||||
const capRows = showChips && !isMobile;
|
||||
|
||||
// The month the user is looking at: sampled a little above the middle of
|
||||
// the viewport. It dims the other months' days and drives the title.
|
||||
// The month the user is looking at: the one with the most days in view. It
|
||||
// dims the other months' days and drives the title. One point on the screen
|
||||
// won't do: in a short pane it hit the week holding the 1st, and today's
|
||||
// month was called by the name of the one before.
|
||||
const [visibleMonthDate, setVisibleMonthDate] = useState<Date>(() => focus.date);
|
||||
const visibleMonthRef = useRef(visibleMonthDate);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
|
||||
// The week at the top of the view and how far it is scrolled, as a share of its
|
||||
// height. Rows change height with the window; see the effect below.
|
||||
const topWeekRef = useRef<{ key: string; progress: number } | null>(null);
|
||||
|
||||
const sampleVisibleMonth = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const hHeight = headerRef.current?.offsetHeight ?? 0;
|
||||
const sampleY = el.getBoundingClientRect().top + hHeight + (el.clientHeight - hHeight) * VISIBLE_MONTH_SAMPLE;
|
||||
const rows = el.querySelectorAll<HTMLElement>("[data-week]");
|
||||
let hit: HTMLElement | null = null;
|
||||
for (const row of rows) {
|
||||
if (row.getBoundingClientRect().bottom >= sampleY) { hit = row; break; }
|
||||
const box = el.getBoundingClientRect();
|
||||
const rows = Array.from(el.querySelectorAll<HTMLElement>("[data-week]"), (row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return { weekStart: parseDayKey(row.dataset.week ?? ""), top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
const viewTop = box.top + hHeight;
|
||||
const topRow = rows.find((row) => row.bottom > viewTop);
|
||||
if (topRow) {
|
||||
topWeekRef.current = {
|
||||
key: dayKey(topRow.weekStart),
|
||||
progress: Math.max(0, viewTop - topRow.top) / Math.max(1, topRow.bottom - topRow.top),
|
||||
};
|
||||
}
|
||||
const weekKey = hit?.dataset.week;
|
||||
if (!weekKey) return;
|
||||
const midWeek = addDays(parseDayKey(weekKey), 3);
|
||||
const day = mostVisibleMonthDay(rows, box.top + hHeight, box.top + el.clientHeight, (d) => `${getYear(d)}-${getMonth(d)}`);
|
||||
if (!day) return;
|
||||
const current = visibleMonthRef.current;
|
||||
if (getMonth(midWeek) === getMonth(current) && getYear(midWeek) === getYear(current)) return;
|
||||
visibleMonthRef.current = midWeek;
|
||||
setVisibleMonthDate(midWeek);
|
||||
onVisibleDateChange?.(midWeek);
|
||||
if (getMonth(day) === getMonth(current) && getYear(day) === getYear(current)) return;
|
||||
visibleMonthRef.current = day;
|
||||
setVisibleMonthDate(day);
|
||||
onVisibleDateChange?.(day);
|
||||
}, [getMonth, getYear, onVisibleDateChange]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
@@ -254,6 +275,35 @@ export function CalendarMonthView({
|
||||
});
|
||||
}, [sampleVisibleMonth]);
|
||||
|
||||
// A resized window or a turned phone changes the height of every week while
|
||||
// the scroll offset stays put, which slid the view to other weeks under a title
|
||||
// that still named the month. Keep the same week at the top. Watched on the
|
||||
// rows themselves, since their height also depends on what is in them.
|
||||
const weekHeightsRef = useRef(new Map<string, number>());
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
let changed = false;
|
||||
el.querySelectorAll<HTMLElement>("[data-week]").forEach((row) => {
|
||||
const key = row.dataset.week ?? "";
|
||||
const height = row.getBoundingClientRect().height;
|
||||
const known = weekHeightsRef.current.get(key);
|
||||
if (known !== undefined && known !== height) changed = true;
|
||||
weekHeightsRef.current.set(key, height);
|
||||
});
|
||||
const anchor = topWeekRef.current;
|
||||
if (!changed || !anchor) return;
|
||||
const row = el.querySelector<HTMLElement>(`[data-week="${anchor.key}"]`);
|
||||
if (!row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const viewTop = el.getBoundingClientRect().top + (headerRef.current?.offsetHeight ?? 0);
|
||||
el.scrollTop += rect.top + anchor.progress * rect.height - viewTop;
|
||||
});
|
||||
el.querySelectorAll("[data-week]").forEach((row) => observer.observe(row));
|
||||
return () => observer.disconnect();
|
||||
}, [weekSegments]);
|
||||
|
||||
// Navigation puts the first week of the focused month at the top.
|
||||
const scrollToFocus = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -265,6 +315,7 @@ export function CalendarMonthView({
|
||||
const hHeight = headerRef.current?.offsetHeight ?? 0;
|
||||
el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - hHeight;
|
||||
}
|
||||
topWeekRef.current = { key: targetKey, progress: 0 };
|
||||
visibleMonthRef.current = focus.date;
|
||||
setVisibleMonthDate(focus.date);
|
||||
}, [focus.date, getMonthGridDays]);
|
||||
@@ -331,10 +382,11 @@ export function CalendarMonthView({
|
||||
}, []);
|
||||
|
||||
const handleCellDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
|
||||
e.preventDefault();
|
||||
setDropDayKey(null);
|
||||
// Anything else (a Pro tab or message moving to this pane) is not ours.
|
||||
const json = e.dataTransfer.getData("application/x-calendar-event");
|
||||
if (!json) return;
|
||||
e.preventDefault();
|
||||
setDropDayKey(null);
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
const originalStart = parseISO(data.originalStart);
|
||||
@@ -393,10 +445,12 @@ export function CalendarMonthView({
|
||||
return hiddenEvents + hiddenTasks;
|
||||
})
|
||||
: null;
|
||||
// Rows that grow with their content make room for the new event's preview.
|
||||
const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day)) ? 1 : 0;
|
||||
return (
|
||||
<div key={dayKey(week[0])} data-week={dayKey(week[0])} className="relative flex-shrink-0 border-b border-border" role="row" style={{
|
||||
minHeight: showChips && !overflowing
|
||||
? Math.max(rowMinHeight, overlayTop + 4 + contentRows * rowHeight + 8)
|
||||
? Math.max(rowMinHeight, overlayTop + 4 + (contentRows + previewRows) * rowHeight + 8)
|
||||
: rowMinHeight,
|
||||
}}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
@@ -528,6 +582,7 @@ export function CalendarMonthView({
|
||||
isMobile={isMobile}
|
||||
calendarMap={calendarMap}
|
||||
pendingPreview={pendingPreview}
|
||||
lastRow={capRows ? rowCapacity - 1 : Infinity}
|
||||
selectedEventId={selectedEventId}
|
||||
currentUserEmails={currentUserEmails}
|
||||
canEditEvent={canEditEvent}
|
||||
@@ -569,7 +624,7 @@ type WeekSegment = ReturnType<typeof buildWeekSegments>[number];
|
||||
*/
|
||||
function MonthCellEntries({
|
||||
dayIndex, week, segments, rowLimit, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight,
|
||||
isMobile, calendarMap, pendingPreview, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle,
|
||||
isMobile, calendarMap, pendingPreview, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle,
|
||||
onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, paint,
|
||||
}: {
|
||||
dayIndex: number;
|
||||
@@ -586,6 +641,8 @@ function MonthCellEntries({
|
||||
isMobile?: boolean;
|
||||
calendarMap: Map<string, Calendar>;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
/** The last row a fixed-height week has room for; the preview must not go past it. */
|
||||
lastRow: number;
|
||||
selectedEventId?: string | null;
|
||||
currentUserEmails?: string[];
|
||||
canEditEvent: (event: CalendarEvent) => boolean;
|
||||
@@ -686,17 +743,18 @@ function MonthCellEntries({
|
||||
</div>
|
||||
)}
|
||||
{preview && (() => {
|
||||
const previewRow = Math.max(rowCount, dayEventRows[dayIndex] + dayTasks[dayIndex].length);
|
||||
// On a full day it lies over the last row: below it, it landed on the next week's date.
|
||||
const previewRow = Math.min(lastRow, Math.max(rowCount, dayEventRows[dayIndex] + dayTasks[dayIndex].length));
|
||||
const color = paint(calendarMap.get(preview.calendarId)?.color || "#3b82f6");
|
||||
return (
|
||||
<div className="absolute z-10 px-0.5 pointer-events-none" style={place(previewRow)}>
|
||||
<div className="absolute z-20 px-0.5 pointer-events-none" style={place(previewRow)}>
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn(
|
||||
"h-full rounded text-[0.625rem] font-medium truncate border-2 border-dashed",
|
||||
isMobile ? "leading-[1rem] px-1" : "leading-[1.25rem] px-1.5"
|
||||
)}
|
||||
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
||||
style={{ borderColor: color, color, backgroundColor: `color-mix(in srgb, ${color} 6%, var(--color-background))` }}
|
||||
>
|
||||
{preview.title}
|
||||
</div>
|
||||
|
||||
@@ -280,13 +280,14 @@ export function CalendarToolbar({
|
||||
{/* Each pill is as wide as its label plus an even share of the rest, so a
|
||||
long label ("جدول الأعمال", "Aufgaben") neither wraps nor touches the edge. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
|
||||
{/* Past the row's room (Finnish, a large text size) the pills scroll rather than slide under the buttons. */}
|
||||
<div className="flex flex-1 min-w-0 border border-border rounded-md overflow-x-auto scroll-hidden">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onViewModeChange(v)}
|
||||
className={cn(
|
||||
"flex-auto min-h-11 px-1.5 text-[0.6875rem] font-medium transition-colors touch-manipulation",
|
||||
"flex-auto shrink-0 whitespace-nowrap min-h-11 px-1.5 text-[0.6875rem] font-medium transition-colors touch-manipulation",
|
||||
v === viewMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground active:bg-muted"
|
||||
@@ -303,8 +304,9 @@ export function CalendarToolbar({
|
||||
<Search />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* On the smallest phones the view names need its room; the drawer lists the calendars too. */}
|
||||
{calendars && selectedCalendarIds && onToggleVisibility && (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative max-[22.5rem]:hidden" ref={dropdownRef}>
|
||||
<IconButton
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
|
||||
@@ -152,7 +152,8 @@ export function CalendarWeekView({
|
||||
|
||||
const measureColWidth = useCallback((root: HTMLElement | null) => {
|
||||
if (isMobile || !root) return MOBILE_COL_WIDTH;
|
||||
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / columnsPerWeek));
|
||||
// Not rounded down: the pixels left over showed a sliver of next Monday at the edge.
|
||||
return Math.max(MIN_COL_WIDTH, (root.clientWidth - gutterWidth) / columnsPerWeek);
|
||||
}, [isMobile, gutterWidth, columnsPerWeek]);
|
||||
const [colWidth, setColWidth] = useState(MOBILE_COL_WIDTH);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
@@ -435,7 +436,7 @@ export function CalendarWeekView({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="flex min-h-0 min-w-0 flex-col flex-1 overflow-auto [overflow-anchor:none]"
|
||||
className="flex min-h-0 min-w-0 flex-col flex-1 overflow-auto [overflow-anchor:none] max-md:pb-20"
|
||||
onScroll={handleStripScroll}
|
||||
role="grid"
|
||||
aria-label={t("views.week")}
|
||||
@@ -510,7 +511,8 @@ export function CalendarWeekView({
|
||||
className={cn(gutterClass, "text-[0.625rem] text-muted-foreground p-1 text-end flex flex-col justify-between")}
|
||||
style={{ height: allDayHeight }}
|
||||
>
|
||||
<span>{t("events.all_day")}</span>
|
||||
{/* A long single word ("Ganztägig") must stay inside the narrow gutter of a phone. */}
|
||||
<span className="hyphens-auto [overflow-wrap:anywhere]">{t("events.all_day")}</span>
|
||||
{isExpandable && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -279,15 +279,17 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{textStyle === "dot" && (
|
||||
<span data-event-dot aria-hidden="true" className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
)}
|
||||
<span className="flex-shrink-0 font-normal opacity-85">{safeFormat(startDate, timeFmt)}</span>
|
||||
<span dir="auto" className="flex-1 min-w-0 truncate text-start font-medium">{event.title || t("events.no_title")}</span>
|
||||
{visibleCalendarName && (
|
||||
<span data-calendar-name dir="auto" className="min-w-0 max-w-[45%] shrink truncate font-normal opacity-85">{visibleCalendarName}</span>
|
||||
)}
|
||||
<div className="@container">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{textStyle === "dot" && (
|
||||
<span data-event-dot aria-hidden="true" className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
)}
|
||||
<span className="hidden @min-[5.5rem]:inline flex-shrink-0 font-normal opacity-85">{safeFormat(startDate, timeFmt)}</span>
|
||||
<span dir="auto" className="flex-1 min-w-0 truncate text-start font-medium">{event.title || t("events.no_title")}</span>
|
||||
{visibleCalendarName && (
|
||||
<span data-calendar-name dir="auto" className="hidden @min-[9rem]:inline min-w-0 max-w-[45%] shrink truncate font-normal opacity-85">{visibleCalendarName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -314,14 +316,18 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted }), ...style }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{showTimeInMonthView && !event.showWithoutTime && (
|
||||
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
|
||||
)}
|
||||
<span dir="auto" className={cn("flex-1 min-w-0 truncate text-start font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
{visibleCalendarName && (
|
||||
<span data-calendar-name dir="auto" className="min-w-0 max-w-[45%] shrink truncate font-normal opacity-85">{visibleCalendarName}</span>
|
||||
)}
|
||||
{/* The title comes first: in a narrow day the time and the calendar's
|
||||
name left it no room, or one letter. */}
|
||||
<div className="@container">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{showTimeInMonthView && !event.showWithoutTime && (
|
||||
<span className="hidden @min-[5.5rem]:inline flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
|
||||
)}
|
||||
<span dir="auto" className={cn("flex-1 min-w-0 truncate text-start font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
{visibleCalendarName && (
|
||||
<span data-calendar-name dir="auto" className="hidden @min-[9rem]:inline min-w-0 max-w-[45%] shrink truncate font-normal opacity-85">{visibleCalendarName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -370,16 +376,20 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Not in a block too narrow for it (several events at once): it broke over three lines of clipped text. */}
|
||||
{!event.showWithoutTime && durationMinutes >= 45 && (
|
||||
<div className="opacity-85 text-[0.65625rem]">
|
||||
{/* Start before end in a right-to-left layout too. */}
|
||||
<bdi dir="ltr">{timeString}</bdi>
|
||||
<div className="@container">
|
||||
<div className="hidden @min-[4.25rem]:block opacity-85 text-[0.65625rem]">
|
||||
{/* Start before end in a right-to-left layout too. */}
|
||||
<bdi dir="ltr">{timeString}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visibleCalendarName && (
|
||||
<div data-calendar-name dir="auto" className="opacity-85 text-[0.65625rem] truncate">{visibleCalendarName}</div>
|
||||
)}
|
||||
{getParticipantCount(event) > 0 && (
|
||||
{/* Below 45 minutes the block has room for the title line only: the count was cut in half. */}
|
||||
{getParticipantCount(event) > 0 && durationMinutes >= 45 && (
|
||||
<div
|
||||
className="flex items-center gap-0.5 opacity-85 text-[0.65625rem]"
|
||||
title={t("participants.count", { count: getParticipantCount(event) })}
|
||||
|
||||
@@ -245,6 +245,7 @@ export function FindATime({ attendees, start, end, onPick, savedWindow }: FindAT
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
data-testid="find-a-time-button"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("suggest")}
|
||||
|
||||
@@ -239,7 +239,7 @@ export function MiniCalendar({
|
||||
key={d}
|
||||
scope="col"
|
||||
abbr={weeks[0]?.[index] ? intlFormatter.dateTime(weeks[0][index], { weekday: "long" }) : undefined}
|
||||
className="text-center text-[0.625rem] font-medium text-muted-foreground py-1"
|
||||
className="overflow-hidden text-center text-[0.625rem] font-medium text-muted-foreground py-1"
|
||||
>
|
||||
{t(`days.${d}`)}
|
||||
</th>
|
||||
@@ -275,12 +275,12 @@ export function MiniCalendar({
|
||||
tabIndex={key === tabStopKey ? 0 : -1}
|
||||
onClick={() => { setFocusedKey(key); onSelectDate(day); }}
|
||||
onKeyDown={(e) => handleDayKeyDown(e, day, column)}
|
||||
className="group/day inline-flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
|
||||
className="group/day inline-flex items-center justify-center w-full max-w-7 h-7 pointer-coarse:max-w-none pointer-coarse:w-full pointer-coarse:h-11 text-xs"
|
||||
aria-current={today ? "date" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
|
||||
"relative flex items-center justify-center w-full max-w-7 aspect-square rounded-full transition-colors",
|
||||
// Other months' days in the muted text colour, which
|
||||
// still reads at 4.5:1 (a faded one did not).
|
||||
!inMonth && "text-muted-foreground",
|
||||
@@ -295,7 +295,7 @@ export function MiniCalendar({
|
||||
<span className="sr-only">{label}</span>
|
||||
<span aria-hidden="true">{formatDayNumber(day)}</span>
|
||||
{hasEvent && !selected && !today && (
|
||||
<span aria-hidden="true" className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
<span aria-hidden="true" className="absolute top-[calc(50%+0.55em)] left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
<PluginDayLabel surface="mini-calendar-marks" date={key} />
|
||||
</span>
|
||||
|
||||
@@ -174,7 +174,7 @@ export function TaskListView({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto max-md:pb-20">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -155,7 +155,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap">
|
||||
{/* May not grow past the pane: in a narrow one the buttons at the end were pushed out of reach. */}
|
||||
<div className="flex items-center gap-1.5 max-w-full flex-wrap">
|
||||
{email && onCompose && (
|
||||
<Button variant="outline" size="xs" onClick={onCompose} className="gap-1.5 touch-manipulation">
|
||||
<Send />
|
||||
@@ -171,11 +172,19 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{t("context_menu.call")}
|
||||
</a>
|
||||
)}
|
||||
<Button variant="outline" size="xs" onClick={onEdit} className="gap-1.5 touch-manipulation">
|
||||
<Pencil />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
{onPrint && (
|
||||
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
|
||||
{isMobile ? (
|
||||
<ToolbarButton label={t("form.edit_title")} onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil />
|
||||
</ToolbarButton>
|
||||
) : (
|
||||
<Button variant="outline" size="xs" onClick={onEdit} className="gap-1.5 touch-manipulation">
|
||||
<Pencil />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
)}
|
||||
{/* On a phone it stays in the ⋮, which has Print too. */}
|
||||
{onPrint && !isMobile && (
|
||||
<ToolbarButton label={tCommon("print")} onClick={onPrint} className="touch-manipulation">
|
||||
<Printer />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -873,10 +873,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
{/* Email */}
|
||||
<FormSection icon={Mail} title={t("email")}>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 @container">
|
||||
{emails.map((entry, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap @md:flex-nowrap items-center gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
@@ -894,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
aria-label={t("email")}
|
||||
aria-invalid={emailErrors[i] ? true : undefined}
|
||||
aria-describedby={emailErrors[i] ? fieldId(`email-error-${i}`) : undefined}
|
||||
className={cn("flex-1", emailErrors[i] && "border-destructive focus-visible:border-destructive")}
|
||||
className={cn("grow basis-full @md:basis-0", emailErrors[i] && "border-destructive focus-visible:border-destructive")}
|
||||
/>
|
||||
<Select
|
||||
value={entry.context}
|
||||
@@ -929,9 +929,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
{/* Phone */}
|
||||
<FormSection icon={Phone} title={t("phone")}>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 @container">
|
||||
{/* In a narrow pane the number takes a line of its own: beside the two selects it was cut to two digits. */}
|
||||
{phones.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div key={i} className="flex flex-wrap @md:flex-nowrap items-center gap-2">
|
||||
<Input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
@@ -943,7 +944,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
}}
|
||||
placeholder={t("phone_placeholder")}
|
||||
aria-label={t("phone")}
|
||||
className="flex-1"
|
||||
className="grow basis-full @md:basis-0"
|
||||
/>
|
||||
<Select
|
||||
value={entry.feature}
|
||||
@@ -952,7 +953,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
next[i] = { ...next[i], feature: e.target.value as PhoneEntry["feature"] };
|
||||
setPhones(next);
|
||||
}}
|
||||
className="w-[5.5rem]"
|
||||
className="w-28"
|
||||
aria-label={t("phone_type")}
|
||||
>
|
||||
<option value="">{t("phone_type")}</option>
|
||||
|
||||
@@ -649,7 +649,7 @@ export function ContactList({
|
||||
aria-label={t("title")}
|
||||
aria-multiselectable
|
||||
onKeyDown={handleListKeyDown}
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="flex-1 overflow-y-auto max-md:pb-20"
|
||||
>
|
||||
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
|
||||
// The rows the page skeleton drew, at the same density.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PaneIdContext } from '@/hooks/use-pane-context';
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
@@ -183,6 +184,32 @@ vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) =>
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('a message row dragged over the composer', () => {
|
||||
const rowDrag = { types: ['application/x-email-ids', 'text/plain'], files: [], getData: () => 'Subject of the row', dropEffect: '' };
|
||||
const subjectField = () => screen.getByDisplayValue('Half-written');
|
||||
const draft = {
|
||||
to: '', cc: '', bcc: '', subject: 'Half-written', body: '', showCc: false, showBcc: false,
|
||||
selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null,
|
||||
};
|
||||
|
||||
it('is stopped in the standard shell, or Subject would take the row\'s subject', () => {
|
||||
render(<EmailComposer initialData={draft} onClose={vi.fn()} />);
|
||||
// fireEvent returns false when the event's default was prevented.
|
||||
expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(false);
|
||||
expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(false);
|
||||
});
|
||||
|
||||
it('is left to the shell inside a Pro pane', () => {
|
||||
render(
|
||||
<PaneIdContext.Provider value="main">
|
||||
<EmailComposer initialData={draft} onClose={vi.fn()} />
|
||||
</PaneIdContext.Provider>,
|
||||
);
|
||||
expect(fireEvent.dragOver(subjectField(), { dataTransfer: rowDrag })).toBe(true);
|
||||
expect(fireEvent.drop(subjectField(), { dataTransfer: rowDrag })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A host that replaces the compose session - a mailto: click, say - must not
|
||||
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
@@ -16,6 +17,9 @@ vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/contacts/directory-picker-dialog', () => ({
|
||||
DirectoryPickerDialog: () => <div role="dialog" aria-label="directory"><input aria-label="directory search" /></div>,
|
||||
}));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
@@ -523,4 +527,40 @@ describe('composer with the @ recipient list', () => {
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets Escape in a formatting menu close only the menu', async () => {
|
||||
const { onClose } = await renderComposer();
|
||||
fireEvent.click(screen.getByTitle('text_color'));
|
||||
const menu = await screen.findByRole('menu', { name: 'text_color' });
|
||||
fireEvent.keyDown(menu.querySelector('button')!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menu', { name: 'text_color' })).toBeNull();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pulls the last recipient back whole on Backspace, without the key deleting its last character', async () => {
|
||||
await renderComposer({ to: 'Sarah Kim <sarah@example.com>' });
|
||||
const to = within(screen.getByTestId('composer-to')).getByRole('combobox') as HTMLInputElement;
|
||||
// fireEvent returns false when the handler stopped the key's default action.
|
||||
expect(fireEvent.keyDown(to, { key: 'Backspace' })).toBe(false);
|
||||
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
|
||||
});
|
||||
|
||||
// The directory picker is drawn inside the composer itself, not portaled.
|
||||
it('lets Escape in the directory picker leave the composer alone', async () => {
|
||||
// Every other capability answers nothing.
|
||||
const client = new Proxy({ supportsPrincipals: () => true }, {
|
||||
get: (target, key) => (key in target ? target[key as keyof typeof target] : () => undefined),
|
||||
});
|
||||
useAuthStore.setState({ client: client as never });
|
||||
try {
|
||||
const { onClose } = await renderComposer();
|
||||
fireEvent.click(screen.getByTestId('composer-directory-picker'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: 'directory search' }), { key: 'Escape' });
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
useAuthStore.setState({ client: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,6 +543,29 @@ describe('composer-sidebar and composer-sidebar-right', () => {
|
||||
expect(panel('right').style.width).toBe('360px');
|
||||
});
|
||||
|
||||
it('keep a panel laid over the composer between its header and its bottom bar', () => {
|
||||
stubWidth(700);
|
||||
offerSlots('p1', ['composer-sidebar-right']);
|
||||
const rect = (top: number, bottom: number) => ({ top, bottom, left: 0, right: 700, width: 700, height: bottom - top, x: 0, y: top, toJSON: () => ({}) });
|
||||
const original = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = function (this: Element) {
|
||||
if (this.matches('[data-testid="email-composer"]')) return rect(100, 800);
|
||||
if (this.matches('[data-composer-header]')) return rect(100, 156);
|
||||
if (this.matches('[data-composer-footer]')) return rect(740, 800);
|
||||
return original.call(this);
|
||||
};
|
||||
try {
|
||||
localStorage.setItem('composerPluginSidebarCollapsed:right', '1');
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
fireEvent.click(document.querySelector('[data-composer-panel-toggle="right"]')!);
|
||||
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
|
||||
expect(panel('right').style.top).toBe('56px');
|
||||
expect(panel('right').style.bottom).toBe('60px');
|
||||
} finally {
|
||||
Element.prototype.getBoundingClientRect = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('folds a widened panel sooner, since it needs the room', () => {
|
||||
localStorage.setItem('composerPluginSidebarWidth:right', '480');
|
||||
stubWidth(1000);
|
||||
|
||||
@@ -246,6 +246,52 @@ describe('composer flush before an automatic sign-out', () => {
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
|
||||
});
|
||||
|
||||
it('asks before closing a composer opened with stashed edits', async () => {
|
||||
const onClose = vi.fn();
|
||||
const requestCloseRef: { current: ((afterClose?: () => void) => void) | null } = { current: null };
|
||||
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={onClose} requestCloseRef={requestCloseRef} />);
|
||||
|
||||
act(() => requestCloseRef.current?.());
|
||||
|
||||
expect(await screen.findByRole('alertdialog')).toBeTruthy();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes a composer opened with text the server already has without asking', () => {
|
||||
const onClose = vi.fn();
|
||||
const requestCloseRef: { current: ((afterClose?: () => void) => void) | null } = { current: null };
|
||||
render(<EmailComposer initialData={{ ...DRAFT, draftId: 'draft-0', unsaved: false }} onClose={onClose} requestCloseRef={requestCloseRef} />);
|
||||
|
||||
act(() => requestCloseRef.current?.());
|
||||
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('autosaves a composer opened with stashed edits', async () => {
|
||||
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
|
||||
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
|
||||
});
|
||||
|
||||
it('hands nothing back for stashed edits it did not change: the host has them', () => {
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={vi.fn()} onSaveState={onSaveState} />);
|
||||
view.unmount();
|
||||
|
||||
expect(onSaveState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands back the draft id an autosave of stashed edits got', async () => {
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={vi.fn()} onSaveState={onSaveState} />);
|
||||
await act(async () => { await flushComposers(5000); });
|
||||
view.unmount();
|
||||
|
||||
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ draftId: 'draft-1', unsaved: false }));
|
||||
});
|
||||
|
||||
it('waits for a send in flight and saves no draft of the sent message', async () => {
|
||||
let finishSend!: () => void;
|
||||
const onSend = vi.fn(() => new Promise<void>((resolve) => { finishSend = resolve; }));
|
||||
|
||||
@@ -71,6 +71,23 @@ describe('plugin list filters in the message list', () => {
|
||||
expect(fetchEmails).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drops ticked messages when a chip narrows the list', async () => {
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b']) });
|
||||
render(<EmailList emails={[]} />);
|
||||
const chip = await screen.findByRole('button', { name: 'Open' });
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
|
||||
act(() => { fireEvent.click(chip); });
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not call the folder empty while a filter hides its mail', async () => {
|
||||
render(<EmailList emails={[]} />);
|
||||
expect(await screen.findByText('no_emails_description')).toBeInTheDocument();
|
||||
act(() => { fireEvent.click(screen.getByRole('button', { name: 'Open' })); });
|
||||
expect(screen.getByText('no_emails')).toBeInTheDocument();
|
||||
expect(screen.queryByText('no_emails_description')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a search', { searchQuery: 'invoice' }],
|
||||
['a tag view', { selectedKeyword: 'work' }],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MessageListTabs, useMessageListPanelProps } from '../message-list-tabs';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The category strip is a tablist in the user's language whose active tab
|
||||
@@ -35,6 +36,7 @@ describe('MessageListTabs', () => {
|
||||
selectedMailbox: 'mb-inbox',
|
||||
selectedKeyword: null,
|
||||
searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
isUnifiedView: false,
|
||||
} as never);
|
||||
});
|
||||
@@ -49,6 +51,15 @@ describe('MessageListTabs', () => {
|
||||
expect(screen.getByRole('tabpanel', { name: 'Primary' })).toHaveTextContent('list');
|
||||
});
|
||||
|
||||
it('drops ticked messages on a switch of tab, which lists other mail', () => {
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b']) } as never);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Primary' }));
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(2);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Social' }));
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps one tab stop and moves with the arrow keys', () => {
|
||||
render(<Harness />);
|
||||
const [primary, promotions, social] = screen.getAllByRole('tab');
|
||||
@@ -71,4 +82,11 @@ describe('MessageListTabs', () => {
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
expect(screen.queryByRole('tabpanel')).toBeNull();
|
||||
});
|
||||
|
||||
// A tab click fetches the folder again, which threw away the filter search.
|
||||
it('stays away during a search that only uses the filters', () => {
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob' } } as never);
|
||||
render(<Harness />);
|
||||
expect(screen.queryByRole('tablist')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -425,6 +425,25 @@ describe('reader surfaces in the phone conversation view', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick reply', () => {
|
||||
it('scrolls its buttons into view when it takes the focus', () => {
|
||||
const scroll = vi.fn();
|
||||
Element.prototype.scrollIntoView = scroll;
|
||||
useSettingsStore.setState({ quickReplyEnabled: true });
|
||||
const { container } = viewer(textMail('Hola'), { onQuickReply: vi.fn() });
|
||||
fireEvent.focus(container.querySelector('textarea')!);
|
||||
expect(scroll).toHaveBeenCalledWith({ block: 'nearest' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recipients row', () => {
|
||||
it('says how many recipients are left out, in the phone row as in the desktop one', () => {
|
||||
const to = ['a', 'b', 'c', 'd'].map((n) => ({ name: n, email: `${n}@example.com` }));
|
||||
viewer(textMail('Hola', { to } as Partial<Email>));
|
||||
expect(screen.getAllByText('more_count')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader-actions', () => {
|
||||
it('adds the plugin buttons to the reader toolbar', async () => {
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
@@ -442,6 +461,32 @@ describe('reader-actions', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('joins the toolbar overflow, giving way before the items of the toolbar', () => {
|
||||
act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'one', label: 'One' }, { id: 'two', label: 'Two' }]));
|
||||
viewer(textMail('Hola'));
|
||||
|
||||
const priority = (name: string) => Number(screen.getByRole('button', { name }).dataset.overflowPriority);
|
||||
expect(priority('One')).toBeGreaterThan(11);
|
||||
expect(priority('Two')).toBeGreaterThan(priority('One'));
|
||||
});
|
||||
|
||||
it('offers the buttons with no room on the toolbar in the more menu, and only those', async () => {
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [
|
||||
{ id: 'fits', label: 'Fits' },
|
||||
{ id: 'translate', label: 'Translate' },
|
||||
]));
|
||||
viewer(textMail('Hola'));
|
||||
// jsdom lays nothing out, so a button without rects reads as hidden.
|
||||
vi.spyOn(screen.getByRole('button', { name: 'Fits' }), 'getClientRects')
|
||||
.mockReturnValue([new DOMRect(0, 0, 40, 32)] as unknown as DOMRectList);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const item = await screen.findByRole('menuitem', { name: 'Translate' });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Fits' })).toBeNull();
|
||||
await act(async () => { fireEvent.click(item); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('puts them in the More panel on phones', async () => {
|
||||
device.isMobile = true;
|
||||
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { undo } from '@tiptap/pm/history';
|
||||
import {
|
||||
applyIssueReplacement, createTextCheckExtension, drawnIssues, selectNextIssue, setTextIssues, type TextIssueRange,
|
||||
applyIssueReplacement, createTextCheckExtension, drawnIssues, selectNextIssue, setActiveIssue, setTextIssues, type TextIssueRange,
|
||||
} from '../proofreading/text-check-extension';
|
||||
|
||||
const editors: Editor[] = [];
|
||||
@@ -54,6 +54,21 @@ describe('text check underlines', () => {
|
||||
expect(drawnIssues(editor.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks the issue whose popup is open, keeps the others, and goes back when it closes', () => {
|
||||
const { editor } = setup('Ich habe dem Brief geschrieben');
|
||||
setTextIssues(editor, [range(editor, 'dem'), range(editor, 'Brief')]);
|
||||
const active = () => [...editor.view.dom.querySelectorAll('.text-issue-active')].map((el) => el.getAttribute('data-text-issue'));
|
||||
|
||||
setActiveIssue(editor, 'dem');
|
||||
expect(active()).toEqual(['dem']);
|
||||
setActiveIssue(editor, 'Brief');
|
||||
expect(active()).toEqual(['Brief']);
|
||||
expect(drawnIssues(editor.state).map((r) => r.key)).toEqual(['dem', 'Brief']);
|
||||
setActiveIssue(editor, null);
|
||||
expect(active()).toEqual([]);
|
||||
expect(drawnIssues(editor.state).map((r) => r.key)).toEqual(['dem', 'Brief']);
|
||||
});
|
||||
|
||||
it('applies a replacement as one undo step', () => {
|
||||
const { editor } = setup('Ich habe dem Brief geschrieben');
|
||||
setTextIssues(editor, [range(editor, 'dem')]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Profiler } from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { TagDisplayContext, type TagDisplay } from '@/hooks/use-tag-display';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
@@ -142,3 +143,45 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem sender line in a narrow list', () => {
|
||||
const display: TagDisplay = { variant: 'badge', placement: 'sender', tight: true };
|
||||
const tagged = (keywords: Record<string, boolean>) => [makeEmail({ keywords: { $seen: true, ...keywords } })];
|
||||
const renderIn = (value: TagDisplay, emails: Email[]) => {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<TagDisplayContext.Provider value={value}>
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={noop} onEmailSelect={noop} />
|
||||
</TagDisplayContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', interfaceLayout: 'default', showColorNames: false });
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox', isUnifiedView: false, selectedKeyword: null, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: 'archive' });
|
||||
});
|
||||
|
||||
const dots = (c: HTMLElement) => c.querySelectorAll('[data-tag-dot]').length;
|
||||
|
||||
it('names a single tag', () => {
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true }));
|
||||
expect(dots(container)).toBe(0);
|
||||
});
|
||||
|
||||
it('turns several tags into dots so the name keeps its room', () => {
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true, '$label:blue': true }));
|
||||
expect(dots(container)).toBe(2);
|
||||
});
|
||||
|
||||
it('turns a tag into a dot next to the folder chip', () => {
|
||||
useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' });
|
||||
const { container } = renderIn(display, tagged({ '$label:red': true }));
|
||||
expect(dots(container)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the names while the list has room', () => {
|
||||
const { container } = renderIn({ ...display, tight: false }, tagged({ '$label:red': true, '$label:blue': true }));
|
||||
expect(dots(container)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useRef } from 'react';
|
||||
import { useProofreading } from '../proofreading/use-proofreading';
|
||||
|
||||
// Switching to plain text keeps the text, so no new check runs and nothing
|
||||
// else tells the hook to read the issues again: they were read while the
|
||||
// textarea wasn't there, which showed "no issues" until the next key press.
|
||||
|
||||
vi.mock('@/lib/plugin-sandbox/checkers', () => {
|
||||
const checker = [{ pluginId: 'p', id: 'c', label: 'C', mode: 'typing', kinds: [] }];
|
||||
return {
|
||||
allCheckers: () => checker,
|
||||
subscribeCheckers: () => () => {},
|
||||
registerCheckTarget: () => () => {},
|
||||
dispatchCheck: vi.fn(),
|
||||
dispatchCheckerAction: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/proofreading/composer-proofreader', () => ({
|
||||
ComposerProofreader: class {
|
||||
constructor(private deps: { readSegments: () => Array<{ id: string; text: string; start: number }> }) {}
|
||||
issues() { return this.deps.readSegments().map((s) => ({ key: s.id, from: s.start, to: s.start + 1, kind: 'spelling', annotation: { message: '', replacements: [] } })); }
|
||||
status() { return { available: true }; }
|
||||
browserSpellcheck() { return true; }
|
||||
edited() {}
|
||||
sync() {}
|
||||
gateChanged() {}
|
||||
dispose() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const NO_OWN = { current: {} };
|
||||
|
||||
function Composer({ plain }: { plain: boolean }) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { issues } = useProofreading({ composerId: 'c1', sessionKey: 'k1', editor: null, textareaRef, plainTextMode: plain, ownOptions: NO_OWN } as never);
|
||||
return (
|
||||
<>
|
||||
<output data-testid="count">{issues.length}</output>
|
||||
{plain && <textarea ref={textareaRef} defaultValue="I will recieve it" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useProofreading on a switch to plain text', () => {
|
||||
it('reads the issues again once the textarea is there', () => {
|
||||
const { rerender } = render(<Composer plain={false} />);
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('0');
|
||||
act(() => { rerender(<Composer plain />); });
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// A plugin's frame in the reader's toolbar row gets its width after it has
|
||||
// mounted. The row used to be measured only when its children changed, so the
|
||||
// frame pushed More and the panel button out of the window.
|
||||
|
||||
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-plugin-slot': name }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
const observed: Array<{ el: Element; fire: () => void }> = [];
|
||||
class FakeResizeObserver {
|
||||
constructor(private cb: (entries: unknown[]) => void) {}
|
||||
observe(el: Element) { observed.push({ el, fire: () => this.cb([{ contentRect: { width: 800 } }]) }); }
|
||||
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;
|
||||
|
||||
describe('reader toolbar with a plugin frame', () => {
|
||||
it('gives way when the frame grows, without any change to the row\'s children', async () => {
|
||||
let frameWidth = 0;
|
||||
const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(() => 400 + frameWidth);
|
||||
const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
|
||||
const offsetWidth = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(80);
|
||||
try {
|
||||
const { container } = render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
const items = () => Array.from(container.querySelectorAll<HTMLElement>('[data-overflow-item]'));
|
||||
expect(items().length).toBeGreaterThan(0);
|
||||
expect(items().some((item) => item.style.display === 'none')).toBe(false);
|
||||
|
||||
const slot = container.querySelector('[data-plugin-slot="toolbar-actions"]');
|
||||
const entry = observed.find((o) => o.el === slot);
|
||||
expect(entry).toBeDefined();
|
||||
|
||||
frameWidth = 400;
|
||||
await act(async () => {
|
||||
entry!.fire();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(items().some((item) => item.style.display === 'none')).toBe(true);
|
||||
} finally {
|
||||
scrollWidth.mockRestore();
|
||||
clientWidth.mockRestore();
|
||||
offsetWidth.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,10 @@
|
||||
// room at all. 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.
|
||||
// can write in. Laid over, it stays between the composer's header and its
|
||||
// bottom bar: the panel's own button, Close and Send stay in reach.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -224,11 +225,37 @@ export function ComposerPanelToggle({
|
||||
aria-pressed={isOpen}
|
||||
data-composer-panel-toggle={side}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<Icon className="h-4 w-4 rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPOSER_HEADER = "[data-composer-header], [data-mv-compose-titlebar], [data-mv-compose-inline-bar]";
|
||||
const COMPOSER_FOOTER = "[data-composer-footer]";
|
||||
|
||||
/** How far a panel laid over the composer keeps off its top and bottom, to leave the header and the bottom bar free. */
|
||||
function useOverlayInsets(active: boolean, panelRef: RefObject<HTMLElement | null>): { top: number; bottom: number } {
|
||||
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
|
||||
useLayoutEffect(() => {
|
||||
const root = panelRef.current?.parentElement;
|
||||
if (!active || !root) return;
|
||||
const header = root.querySelector(COMPOSER_HEADER);
|
||||
const footer = root.querySelector(COMPOSER_FOOTER);
|
||||
const measure = () => {
|
||||
const box = root.getBoundingClientRect();
|
||||
const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom - box.top)) : 0;
|
||||
const bottom = footer ? Math.max(0, Math.round(box.bottom - footer.getBoundingClientRect().top)) : 0;
|
||||
setInsets((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
for (const el of [root, header, footer]) if (el) observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [active, panelRef]);
|
||||
return insets;
|
||||
}
|
||||
|
||||
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
|
||||
export function ComposerPluginSidebar({
|
||||
side,
|
||||
@@ -246,6 +273,8 @@ export function ComposerPluginSidebar({
|
||||
onWidthChange?: (width: number) => void;
|
||||
}) {
|
||||
const dragStart = useRef(width);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const insets = useOverlayInsets(mode === "overlay", panelRef);
|
||||
if (!mode) return null;
|
||||
const border = side === "left" ? "border-e" : "border-s";
|
||||
|
||||
@@ -274,12 +303,13 @@ export function ComposerPluginSidebar({
|
||||
<>
|
||||
{side === "right" && handle}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
|
||||
"hidden md:flex flex-col shrink-0 border-border bg-background overflow-hidden",
|
||||
border,
|
||||
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
|
||||
mode === "overlay" ? cn("absolute z-30 shadow-xl", side === "left" ? "start-0" : "end-0") : "h-full",
|
||||
)}
|
||||
style={{ width }}
|
||||
style={mode === "overlay" ? { width, top: insets.top, bottom: insets.bottom } : { width }}
|
||||
data-composer-sidebar={side}
|
||||
data-composer-sidebar-mode={mode}
|
||||
>
|
||||
|
||||
@@ -80,6 +80,8 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
|
||||
import { dragKindFromTypes } from "@/components/pro/pro-shell-drop";
|
||||
import { useIsPaneScoped } from "@/hooks/use-pane-context";
|
||||
import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
@@ -177,6 +179,15 @@ import type { RescuedDraft } from "@/lib/popout/channel";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { setComposerFileTarget } from "@/lib/desktop/window-files";
|
||||
|
||||
// A Pro tab, message or folder dragged across a composer tab is on its way to
|
||||
// the pane or the split edge: those drags belong to the shell. There is no shell
|
||||
// outside Pro, and the composer stops them like any other drag: a message row
|
||||
// carries its subject as text, which To and Subject would take.
|
||||
function useIsShellDrag() {
|
||||
const isPaneScoped = useIsPaneScoped();
|
||||
return useCallback((e: React.DragEvent) => isPaneScoped && dragKindFromTypes(e.dataTransfer.types) !== null, [isPaneScoped]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the text/plain alternative from the composer's HTML body, preserving
|
||||
* line structure from block elements and <br> tags. Paragraph spacing is on so
|
||||
@@ -395,6 +406,8 @@ interface EmailComposerProps {
|
||||
*/
|
||||
rescueDraft?: (draft: RescuedDraft) => boolean;
|
||||
};
|
||||
/** The subject as typed, for a host that shows it (a Pro compose tab's title). */
|
||||
onSubjectChange?: (subject: string) => void;
|
||||
className?: string;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
@@ -549,6 +562,7 @@ export function EmailComposer({
|
||||
onSaveState,
|
||||
popOut,
|
||||
ownWindow,
|
||||
onSubjectChange,
|
||||
className,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
@@ -1847,6 +1861,10 @@ export function EmailComposer({
|
||||
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
|
||||
const initialLabelChoiceRef = useRef(classification.choice);
|
||||
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
|
||||
// Text the host handed in is no edit to hand back: it has its own copy, and a
|
||||
// stand-in mounted before the edits arrived must not overwrite them.
|
||||
const hasEditsRef = useRef(false);
|
||||
hasEditsRef.current = isDirtyRef.current;
|
||||
// Text handed in that the server doesn't have yet is unsaved work, however
|
||||
// little it differs from what this composer started with.
|
||||
if (initialData?.unsaved && changeVersionRef.current !== savedVersionRef.current) isDirtyRef.current = true;
|
||||
@@ -1937,13 +1955,20 @@ export function EmailComposer({
|
||||
requireTls,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onSubjectChange?.(subject);
|
||||
}, [onSubjectChange, subject]);
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
// Kept in the host's memory across a remount, handed-in or not. Only
|
||||
// the server stays out of it.
|
||||
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current());
|
||||
// Kept in the host's memory across a remount, handed-in or not. Only the
|
||||
// server stays out of it. Only what the host has no copy of: an edit, or a
|
||||
// draft saved under a new id.
|
||||
if (onSaveState && (hasEditsRef.current || uneditedRef.current || draftIdRef.current !== (initialData?.draftId ?? null))) {
|
||||
onSaveState(snapshotStateRef.current());
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -2643,35 +2668,40 @@ export function EmailComposer({
|
||||
dragTimeoutRef.current = setTimeout(clearDragState, 150);
|
||||
}, [clearDragState]);
|
||||
|
||||
const isShellDrag = useIsShellDrag();
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
resetDragTimeout();
|
||||
}
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDragTimeout();
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDragTimeout();
|
||||
}, [resetDragTimeout]);
|
||||
}, [isShellDrag, resetDragTimeout]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearDragState();
|
||||
if (e.dataTransfer.files?.length) {
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}, [addFiles, clearDragState]);
|
||||
}, [addFiles, clearDragState, isShellDrag]);
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
const att = attachments[index];
|
||||
@@ -4539,6 +4569,11 @@ export function EmailComposer({
|
||||
// through React's tree although the dialog portals to the body: they
|
||||
// belong to that dialog, and Escape must close only it.
|
||||
if (!(e.target instanceof Node) || !e.currentTarget.contains(e.target)) return;
|
||||
// Same for what is drawn inside the composer: the directory picker and the
|
||||
// formatting toolbar's menus, which close on Escape themselves. This
|
||||
// handler runs in the capture phase, so they would never see it.
|
||||
const targetDialog = e.target instanceof Element ? e.target.closest('[role="dialog"],[role="alertdialog"],[role="menu"]') : null;
|
||||
if (targetDialog && e.currentTarget.contains(targetDialog)) return;
|
||||
|
||||
const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
|
||||
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
|
||||
@@ -4821,15 +4856,17 @@ export function EmailComposer({
|
||||
<div ref={composerFieldsRef} className="shrink-0 space-y-0 border-b">
|
||||
{/* From field */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
<span className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('from')}:</span>
|
||||
<div className={cn("flex-1 flex items-center gap-1 min-w-0", fromOverrideEnabled && "flex-wrap")}>
|
||||
{fromOverrideEnabled ? (
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
// Both fields need room to type in: on a narrow composer they take a
|
||||
// line of their own, or a line each, rather than squeeze to nothing.
|
||||
<div className="flex-1 flex flex-wrap items-center gap-1 min-w-52">
|
||||
<Input
|
||||
value={fromOverrideName}
|
||||
onChange={(e) => setFromOverrideName(e.target.value)}
|
||||
placeholder={t('from_override.name_placeholder')}
|
||||
className="h-7 text-sm w-32 md:w-40 shrink-0"
|
||||
className="h-7 text-sm flex-1 min-w-32 md:flex-none md:w-40"
|
||||
aria-label={t('from_override.name_label')}
|
||||
/>
|
||||
<Input
|
||||
@@ -4837,7 +4874,7 @@ export function EmailComposer({
|
||||
onChange={(e) => setFromOverrideEmail(e.target.value)}
|
||||
placeholder={t('from_override.email_placeholder')}
|
||||
type="email"
|
||||
className="h-7 text-sm flex-1 min-w-0 font-mono"
|
||||
className="h-7 text-sm flex-[2] min-w-32 font-mono"
|
||||
aria-label={t('from_override.email_label')}
|
||||
/>
|
||||
</div>
|
||||
@@ -4950,7 +4987,7 @@ export function EmailComposer({
|
||||
|
||||
{/* To field */}
|
||||
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<span id={fieldLabelId('to')} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
|
||||
<span id={fieldLabelId('to')} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('to')}:</span>
|
||||
<RecipientChipInput
|
||||
labelId={fieldLabelId('to')}
|
||||
chips={to}
|
||||
@@ -5009,6 +5046,7 @@ export function EmailComposer({
|
||||
}}
|
||||
onDragLeave={() => setIsDraggingChipOverCc(false)}
|
||||
onDrop={(e) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
setIsDraggingChipOverCc(false);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
@@ -5033,6 +5071,7 @@ export function EmailComposer({
|
||||
}}
|
||||
onDragLeave={() => setIsDraggingChipOverBcc(false)}
|
||||
onDrop={(e) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
setIsDraggingChipOverBcc(false);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
@@ -5050,7 +5089,7 @@ export function EmailComposer({
|
||||
{/* Cc field */}
|
||||
{showCc && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
|
||||
<span id={fieldLabelId('cc')} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
|
||||
<span id={fieldLabelId('cc')} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('cc_label')}</span>
|
||||
<RecipientChipInput
|
||||
labelId={fieldLabelId('cc')}
|
||||
chips={cc}
|
||||
@@ -5081,7 +5120,7 @@ export function EmailComposer({
|
||||
{/* Bcc field */}
|
||||
{showBcc && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
|
||||
<span id={fieldLabelId('bcc')} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
|
||||
<span id={fieldLabelId('bcc')} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('bcc_label')}</span>
|
||||
<RecipientChipInput
|
||||
labelId={fieldLabelId('bcc')}
|
||||
chips={bcc}
|
||||
@@ -5111,7 +5150,7 @@ export function EmailComposer({
|
||||
|
||||
{/* Subject field */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5">
|
||||
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</label>
|
||||
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground min-w-12 md:min-w-16 shrink-0 whitespace-nowrap">{t('subject_label')}</label>
|
||||
<Input
|
||||
id={`${fieldIdBase}-subject`}
|
||||
ref={subjectInputRef}
|
||||
@@ -5398,14 +5437,15 @@ export function EmailComposer({
|
||||
one row (see [data-mv-compose-footer] in globals.css): Send first,
|
||||
the tools after it, and Discard as a bin at the far end. */}
|
||||
<div
|
||||
data-composer-footer=""
|
||||
data-mv-compose-footer={mvWindow ? "" : undefined}
|
||||
className={cn(
|
||||
"@container/composer-bar flex items-center justify-between px-4 py-2.5 max-md:py-1.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
|
||||
mvWindow ? "gap-1" : "border-t",
|
||||
)}
|
||||
>
|
||||
{/* Left side actions */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* Left side actions. Plugins add to them: past the room they wrap, not under Discard. */}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -5584,9 +5624,13 @@ export function EmailComposer({
|
||||
variant="ghost"
|
||||
onClick={handleDiscardClick}
|
||||
data-testid="composer-discard"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
aria-label={t('discard')}
|
||||
title={t('discard')}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 md:@max-[26rem]/composer-bar:px-2.5 max-md:@max-[23rem]/composer-bar:px-2.5"
|
||||
>
|
||||
{t('discard')}
|
||||
{/* Next to Send in a narrow composer the word runs over the tools on the left. */}
|
||||
<Trash2 className="hidden w-4 h-4 max-md:@max-[23rem]/composer-bar:block md:block md:@[26rem]/composer-bar:hidden" aria-hidden="true" />
|
||||
<span className="max-md:@max-[23rem]/composer-bar:hidden md:hidden md:@[26rem]/composer-bar:inline">{t('discard')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
@@ -6264,6 +6308,8 @@ export function RecipientChipInput({
|
||||
// Backspace on an empty input pulls the last chip back into the input for
|
||||
// quick editing.
|
||||
if (e.key === 'Backspace' && !inputText && chips.length > 0) {
|
||||
// Or the key goes on to delete the closing ">" of what was just put back.
|
||||
e.preventDefault();
|
||||
const lastChip = chips[chips.length - 1];
|
||||
onChipsChange(chips.slice(0, -1));
|
||||
onInputChange(formatRecipient(lastChip.name, lastChip.email));
|
||||
@@ -6352,9 +6398,11 @@ export function RecipientChipInput({
|
||||
setDropIndex(past ? index + 1 : index);
|
||||
};
|
||||
|
||||
const isShellDrag = useIsShellDrag();
|
||||
// Insert the dragged chip at `target`. Same-field is a local reorder;
|
||||
// cross-field routes through onMoveChip with the destination index (#593).
|
||||
const performDrop = (e: React.DragEvent, target: number) => {
|
||||
if (isShellDrag(e)) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
@@ -6402,7 +6450,8 @@ export function RecipientChipInput({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1 min-h-[32px] cursor-text",
|
||||
// A long list scrolls by itself, or a reply-all to 80 people leaves no room for the text.
|
||||
"flex flex-wrap items-center gap-1 min-h-[32px] max-h-[min(30dvh,10rem)] overflow-y-auto p-0.5 -m-0.5 cursor-text",
|
||||
validationError && "ring-2 ring-red-500 dark:ring-red-400 rounded",
|
||||
isDragOver && "ring-2 ring-primary/50 rounded bg-accent/20"
|
||||
)}
|
||||
@@ -6452,7 +6501,7 @@ export function RecipientChipInput({
|
||||
onDragOver={(e) => handleChipDragOver(e, i)}
|
||||
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||
"inline-flex items-center gap-1 max-w-full min-w-0 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||
isEditing
|
||||
? "bg-background ring-1 ring-ring"
|
||||
: ( customColor && colorStyles[customColor]
|
||||
@@ -6517,7 +6566,7 @@ export function RecipientChipInput({
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 max-w-[200px]"
|
||||
className="inline-flex items-center gap-1 min-w-0 max-w-[200px]"
|
||||
title={chip.group ? chip.group.members.map(m => m.email).join(', ') : undefined}
|
||||
>
|
||||
{chip.group && <Users className="w-3 h-3 shrink-0" aria-hidden />}
|
||||
@@ -6527,7 +6576,7 @@ export function RecipientChipInput({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex items-center justify-center w-6 h-6 -m-1 rounded-full hover:bg-muted-foreground/20 transition-colors", TOUCH_HIT_AREA)}
|
||||
className={cn("flex items-center justify-center shrink-0 w-6 h-6 -m-1 rounded-full hover:bg-muted-foreground/20 transition-colors", TOUCH_HIT_AREA)}
|
||||
aria-label={isEditing ? t('recipient_remove') : t('recipient_remove_named', { name: recipientDisplayName(chip) })}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -135,6 +135,8 @@ interface EmailContextMenuProps {
|
||||
extras?: MessageActionEntry[];
|
||||
/** The row's plugin entries (message-row-actions). Default true. */
|
||||
rowPlugins?: boolean;
|
||||
/** Entries a caller adds after the message's own, such as its toolbar's overflow. */
|
||||
extraEntries?: React.ReactNode;
|
||||
/** The menu's accessible name, e.g. "More actions" from the reader's ⋮. */
|
||||
label?: string;
|
||||
}
|
||||
@@ -192,6 +194,7 @@ export function EmailContextMenu({
|
||||
omit,
|
||||
extras,
|
||||
rowPlugins = true,
|
||||
extraEntries,
|
||||
label,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
@@ -571,6 +574,7 @@ export function EmailContextMenu({
|
||||
<>
|
||||
{singleMenu}
|
||||
{rowPlugins && rowPluginBlock}
|
||||
{extraEntries}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -282,15 +282,18 @@ export function EmailHoverActions({
|
||||
});
|
||||
if (pluginActionCount > 0) {
|
||||
actionButtons.push(
|
||||
<PluginActions
|
||||
key="plugin-actions"
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
/>,
|
||||
// In a narrow list the row's menu has these: all of them in a row would
|
||||
// lie over the avatar, where a click meant to select hits Delete.
|
||||
<div key="plugin-actions" className="hidden @[20rem]/mail-row:flex">
|
||||
<PluginActions
|
||||
surface="message-row-actions"
|
||||
context={pluginContext}
|
||||
target={pluginTarget}
|
||||
mailboxRole={mailboxRole}
|
||||
compact
|
||||
className="flex-nowrap gap-0.5"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { measureVisibleRow } from "@/lib/virtual-row-measure";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
@@ -49,7 +50,7 @@ import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
||||
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginListFilters } from "./plugin-list-filters";
|
||||
import { PluginListFilters, useFolderListFiltered } from "./plugin-list-filters";
|
||||
import { PluginActions, useFolderAccountKind } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted";
|
||||
@@ -123,7 +124,7 @@ type ListItem =
|
||||
| { kind: 'unread-empty' };
|
||||
|
||||
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
|
||||
const BATCH_TOOLBAR_WIDE = 480;
|
||||
const BATCH_TOOLBAR_WIDE = 640;
|
||||
|
||||
// Declared out here so re-renders of the list keep the same element. Inside
|
||||
// EmailList every render made a new component, which remounted the skeleton
|
||||
@@ -495,8 +496,7 @@ export function EmailList({
|
||||
// Keep sub-pixel row heights. The default measurer rounds them, so a
|
||||
// row could start up to half a pixel inside the one above it and paint
|
||||
// over that row's divider (at 125% or 150% display scaling).
|
||||
measureElement: (element, entry) =>
|
||||
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height,
|
||||
measureElement: measureVisibleRow,
|
||||
});
|
||||
|
||||
|
||||
@@ -534,6 +534,9 @@ export function EmailList({
|
||||
);
|
||||
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
||||
const folderAccountKind = useFolderAccountKind();
|
||||
// A filter chip or a category tab on: the folder itself may well have mail.
|
||||
const pluginFilterOn = useFolderListFiltered(effectiveMailboxRole ?? null, folderAccountKind);
|
||||
const listNarrowed = pluginFilterOn || useMessageListTabsStore.getState().getCategoryFilter(effectiveMailboxRole) !== null;
|
||||
// "Remove from this folder" (#615): offered for a selection only when every
|
||||
// selected message is also in another folder.
|
||||
const removeFromFolder = useRemoveFromFolder();
|
||||
@@ -982,6 +985,12 @@ export function EmailList({
|
||||
`${items.length}:${renderedRows[0]?.index ?? -1}-${renderedRows[renderedRows.length - 1]?.index ?? -1}:${expandedThreadIds.size}:${paged ? page : -1}`,
|
||||
);
|
||||
|
||||
// Room under the last row for the round Compose button, which would otherwise
|
||||
// sit on that row's actions (Mountain View has no such button). A spacer and
|
||||
// not padding on the list: padding is a minimum height the list can't go
|
||||
// under, and on a short screen it pushed the rows over the tab bar.
|
||||
const composeButtonRoom = !mountainViewList ? <div aria-hidden="true" className="h-20" /> : null;
|
||||
|
||||
return (
|
||||
<TagDisplayContext.Provider value={tagDisplay}>
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
@@ -1143,9 +1152,9 @@ export function EmailList({
|
||||
|
||||
{/* Empty Folder Banner for Junk/Trash */}
|
||||
{isEmptyableFolder && emails.length > 0 && !hasSelection && (
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1153,7 +1162,7 @@ export function EmailList({
|
||||
size="sm"
|
||||
onClick={handleEmptyFolder}
|
||||
disabled={isProcessing}
|
||||
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||
className="shrink-0 whitespace-nowrap text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin me-1" />
|
||||
@@ -1170,7 +1179,7 @@ export function EmailList({
|
||||
<PluginListFilters
|
||||
mailboxRole={effectiveMailboxRole ?? null}
|
||||
accountKind={folderAccountKind}
|
||||
onChange={() => { if (client) void useEmailStore.getState().fetchEmails(client); }}
|
||||
onChange={() => { clearSelection(); if (client) void useEmailStore.getState().fetchEmails(client); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1192,9 +1201,7 @@ export function EmailList({
|
||||
// Where the focus goes when the message it was on is deleted,
|
||||
// archived or moved (components/layout/focus-rescue).
|
||||
data-focus-fallback=""
|
||||
// Room under the last row for the round Compose button, which would
|
||||
// otherwise sit on that row's actions (Mountain View has no such button).
|
||||
className={cn("flex-1 overflow-y-auto bg-background relative outline-none", !mountainViewList && "pb-20")}
|
||||
className="flex-1 min-h-0 overflow-y-auto bg-background relative outline-none"
|
||||
data-tour="email-list"
|
||||
// The column header above keeps the same gutter, so its titles stay over the columns.
|
||||
style={listColumns ? { scrollbarGutter: 'stable' } : undefined}
|
||||
@@ -1224,7 +1231,7 @@ export function EmailList({
|
||||
<EmptyState
|
||||
icon={isScheduledView ? CalendarClock : isSearching ? SearchX : MailX}
|
||||
title={isScheduledView ? t('no_scheduled_emails') : isSearching ? t('no_search_results') : t('no_emails')}
|
||||
description={isScheduledView ? t('no_scheduled_emails_description') : isSearching ? t('no_search_results_description') : t('no_emails_description')}
|
||||
description={isScheduledView ? t('no_scheduled_emails_description') : isSearching ? t('no_search_results_description') : listNarrowed ? undefined : t('no_emails_description')}
|
||||
actions={!isScheduledView && isSearching && onClearSearch ? (
|
||||
<Button size="sm" variant="outline" onClick={onClearSearch}>{tSidebar('clear_search')}</Button>
|
||||
) : !isScheduledView && !isSearching && effectiveMailboxRole === 'drafts' && onCompose ? (
|
||||
@@ -1258,6 +1265,7 @@ export function EmailList({
|
||||
{footerIsLoadingMore ? t('loading_more') : t('pages.next')}
|
||||
</Button>
|
||||
</nav>
|
||||
{composeButtonRoom}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -1314,6 +1322,7 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{composeButtonRoom}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildContactsPath } from "@/lib/deep-links";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
|
||||
@@ -212,6 +212,7 @@ const TOOLBAR_PRIORITY_ACTIONS: Record<string, MessageActionKey> = {
|
||||
'11': 'extra:view_mode',
|
||||
};
|
||||
const NO_ACTION_KEYS: ReadonlySet<MessageActionKey> = new Set();
|
||||
const NO_PLUGIN_KEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
/** Whatever a sub-view offers to act on, in the order it is read out. */
|
||||
const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
|
||||
@@ -1115,10 +1116,12 @@ interface ReaderMoreMenuProps {
|
||||
selectedMailbox: string;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
/** The plugin buttons that gave way on the toolbar, as entries of the menu. */
|
||||
pluginEntries?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** The reader's ⋮ on a desktop: the row's context menu, anchored to the button (R6). */
|
||||
function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMoveToMailbox, onSetTag }: ReaderMoreMenuProps) {
|
||||
function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMoveToMailbox, onSetTag, pluginEntries }: ReaderMoreMenuProps) {
|
||||
const { email } = options;
|
||||
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
|
||||
return (
|
||||
@@ -1155,6 +1158,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
|
||||
omit={options.omit}
|
||||
extras={options.extras}
|
||||
rowPlugins={false}
|
||||
extraEntries={pluginEntries}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1637,6 +1641,11 @@ export function EmailViewer({
|
||||
const [quickReplyText, setQuickReplyText] = useState("");
|
||||
const quickReplyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
||||
const quickReplyBoxRef = useRef<HTMLDivElement>(null);
|
||||
// Focused, the box grows by its buttons: bring them up, they'd sit below the fold.
|
||||
useEffect(() => {
|
||||
if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [isQuickReplyFocused]);
|
||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||
/** The recipients and identity the quick reply would be sent with. */
|
||||
const quickMetadata = (): ComposerMetadata => {
|
||||
@@ -1712,6 +1721,8 @@ export function EmailViewer({
|
||||
const tagMenuOpen = tagMenu.contextMenu.isOpen;
|
||||
// What the toolbar shows when the ⋮ opens; the menu leaves those out.
|
||||
const [readerMenuOmit, setReaderMenuOmit] = useState<ReadonlySet<MessageActionKey>>(NO_ACTION_KEYS);
|
||||
// The plugin buttons that had no room on the toolbar when the ⋮ opened.
|
||||
const [readerMenuPluginKeys, setReaderMenuPluginKeys] = useState<ReadonlySet<string>>(NO_PLUGIN_KEYS);
|
||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||
const moreButtonRef = useRef<HTMLButtonElement>(null);
|
||||
// The rows that open a sub-view, and the pieces of the mobile panel a
|
||||
@@ -2119,8 +2130,22 @@ export function EmailViewer({
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
|
||||
// A plugin's frame in the row gets its width some time after it mounts
|
||||
// and changes it when it likes, without a change to the row's children.
|
||||
const slotObserver = new ResizeObserver(() => {
|
||||
measuredToolbarRef.current.clear();
|
||||
scheduleCalculate();
|
||||
});
|
||||
const observeSlots = () => {
|
||||
el.querySelectorAll('[data-plugin-slot="toolbar-actions"]').forEach((slot) => slotObserver.observe(slot));
|
||||
};
|
||||
observeSlots();
|
||||
|
||||
// Recalculate when children change (conditional items, label visibility)
|
||||
const mutationObserver = new MutationObserver(scheduleCalculate);
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
observeSlots();
|
||||
scheduleCalculate();
|
||||
});
|
||||
mutationObserver.observe(el, { childList: true, subtree: true });
|
||||
|
||||
// Initial synchronous calculation to avoid flash
|
||||
@@ -2129,6 +2154,7 @@ export function EmailViewer({
|
||||
return () => {
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
resizeObserver.disconnect();
|
||||
slotObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
@@ -4060,6 +4086,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
ref={setViewerRootEl}
|
||||
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
{/* The loaded reader's print copy. Its place has to stay taken: with the
|
||||
row one child further along, React starts the row, and the side
|
||||
panel's frame in it, over with every message. */}
|
||||
{null}
|
||||
{top}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
|
||||
{content}
|
||||
@@ -4093,8 +4123,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
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">
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
<div className="m-auto text-center p-8 max-w-md">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Bulwark Mail"
|
||||
@@ -4138,10 +4168,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
// in a split pane - the empty reading pane should always say what it is
|
||||
// and offer a way forward.
|
||||
return shell(
|
||||
<div className="relative flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
<div className="relative flex-1 min-w-0 flex flex-col overflow-y-auto bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
{/* An empty reading pane's top is still the window's top, to drag by. */}
|
||||
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
|
||||
<div className="text-center p-8">
|
||||
<div className="m-auto 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" />
|
||||
</div>
|
||||
@@ -4192,6 +4222,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
}
|
||||
return shown;
|
||||
};
|
||||
// The plugin buttons that gave way on the toolbar: the ⋮ menu offers them.
|
||||
const toolbarHiddenPluginActions = (): ReadonlySet<string> => {
|
||||
const hidden = new Set<string>();
|
||||
toolbarRef.current?.querySelectorAll<HTMLElement>('[data-plugin-action-key]').forEach((item) => {
|
||||
if (item.getClientRects().length === 0) hidden.add(item.dataset.pluginActionKey!);
|
||||
});
|
||||
return hidden;
|
||||
};
|
||||
const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev);
|
||||
// The reader's own entries after the message's: the view toggle, Import
|
||||
// (into the open folder) and, with a keyboard, the shortcuts.
|
||||
@@ -4303,16 +4341,16 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('reschedule_send')}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('reschedule_send')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reschedule_send')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
</Button>
|
||||
{onCancelScheduledSend && !email.isSmimeScheduled && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledSend} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={t('cancel_scheduled_send')} data-testid="cancel-scheduled-send">
|
||||
<CalendarX className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('cancel_scheduled_send')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[52rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('cancel_scheduled_send')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -4341,7 +4379,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('reply')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4353,7 +4391,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('reply_all')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('reply_all')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4365,7 +4403,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm whitespace-nowrap">{t('forward')}</span>}
|
||||
{showToolbarLabels && <span className="hidden @[30rem]/reader-toolbar:inline text-sm whitespace-nowrap">{t('forward')}</span>}
|
||||
</Button>
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
@@ -4377,6 +4415,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
compact={!showToolbarLabels}
|
||||
overflowItems
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -4396,7 +4435,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.archive')}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{t('archive')}</span>}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('archive')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
{/* Delete */}
|
||||
@@ -4408,7 +4447,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={t('tooltips.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{t('delete')}</span>}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('delete')}</span>}
|
||||
</Button>
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
@@ -4428,7 +4467,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
aria-expanded={moveMenuOpen}
|
||||
>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{t('move')}</span>}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
@@ -4460,7 +4499,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
aria-expanded={tagMenuOpen}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('tag')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={tagMenu.menuRef} isOpen={tagMenuOpen} position={tagMenu.contextMenu.position} onClose={closeTagMenu} label={t('tag')}>
|
||||
<div className="w-56">
|
||||
@@ -4495,7 +4534,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
) : (
|
||||
<ShieldAlert className={cn("h-4 w-4", !mvViewer && "text-red-600 dark:text-red-400")} />
|
||||
)}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:text-sm">{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}</span>}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -4517,7 +4556,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
>
|
||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||
{showToolbarLabels && (
|
||||
<span className="grid text-center text-[0.625rem] leading-tight sm:text-sm">
|
||||
<span className="grid text-center text-[0.625rem] leading-tight sm:text-sm sm:whitespace-nowrap">
|
||||
<span className={cn("col-start-1 row-start-1", !isUnread && "invisible")} aria-hidden={!isUnread}>{t('read')}</span>
|
||||
<span className={cn("col-start-1 row-start-1", isUnread && "invisible")} aria-hidden={isUnread}>{t('unread')}</span>
|
||||
</span>
|
||||
@@ -4618,6 +4657,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
return;
|
||||
}
|
||||
setReaderMenuOmit(toolbarShownActions());
|
||||
setReaderMenuPluginKeys(toolbarHiddenPluginActions());
|
||||
readerMenu.openContextMenuFrom(e.currentTarget, null, 'end');
|
||||
}}
|
||||
>
|
||||
@@ -4633,6 +4673,20 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMoveToMailbox={onMoveToMailbox}
|
||||
onSetTag={onSetTag ? (tagId) => onSetTag(email.id, tagId) : undefined}
|
||||
pluginEntries={readerMenuPluginKeys.size > 0 ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<PluginActions
|
||||
surface="reader-actions"
|
||||
variant="menu"
|
||||
context={readerActionContext}
|
||||
target={readerActionTarget}
|
||||
mailboxRole={currentMailboxRole ?? null}
|
||||
only={readerMenuPluginKeys}
|
||||
onRan={closeReaderMenu}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -4661,7 +4715,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
|
||||
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
|
||||
className={cn("@container/reader-toolbar flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
|
||||
>
|
||||
{renderToolbarItems(true)}
|
||||
{mvViewer && mvPosition && (
|
||||
@@ -4790,7 +4844,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}>
|
||||
{/* Mountain View lines the subject up with the sender's name below it. */}
|
||||
<div className={cn("px-4 lg:px-6", mvViewer && "lg:ps-[80px] pt-3 pb-1")} style={mvViewer ? undefined : { paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start justify-between gap-2 lg:gap-4">
|
||||
{/* In a narrow pane the date takes its own line instead of squeezing the subject to a column. */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1 lg:gap-x-4">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
@@ -4803,12 +4858,13 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-[min(100%,16rem)]">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* h2 under the app's h1 ("Inbox – Mail"); the reader's
|
||||
blocks below are h3 and h4, so no level is skipped. */}
|
||||
<h2 dir="auto" className={cn(
|
||||
"text-foreground break-words min-w-0",
|
||||
// The header stays while the message scrolls: a very long subject scrolls by itself.
|
||||
"text-foreground break-words min-w-0 max-h-[35dvh] overflow-y-auto",
|
||||
mvViewer ? "text-[1.375rem] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
@@ -4920,7 +4976,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{toolbarPosition === 'below-subject' && (
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-1.5">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
<div ref={toolbarRef} className="@container/reader-toolbar flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
{renderToolbarItems(false)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -5115,7 +5171,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1 whitespace-nowrap"
|
||||
>
|
||||
{showFullHeaders ? (
|
||||
<>
|
||||
@@ -5218,6 +5274,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
<>
|
||||
<span>→ {t('recipient_to_prefix')}</span>
|
||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||
{email.to.length > 2 && (
|
||||
<button
|
||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||
className="text-primary hover:underline text-sm"
|
||||
>
|
||||
{t('more_count', { count: email.to.length - 2 })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{email.cc && email.cc.length > 0 && (
|
||||
@@ -5237,7 +5301,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1 whitespace-nowrap"
|
||||
>
|
||||
{showFullHeaders ? (
|
||||
<>
|
||||
@@ -5609,10 +5673,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</Row>
|
||||
{effectiveAttachments.length > 0 && (
|
||||
<Row label={t('attachments')}>
|
||||
{t('details.attachments_summary', {
|
||||
count: effectiveAttachments.length,
|
||||
size: formatFileSize(totalAttachmentSize),
|
||||
})}
|
||||
{t('attachments_count', { count: effectiveAttachments.length })} · {formatFileSize(totalAttachmentSize)}
|
||||
</Row>
|
||||
)}
|
||||
{email.accountLabel && (
|
||||
@@ -5994,7 +6055,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
|
||||
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
|
||||
{showQuickReply && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
{showQuickReply && (<div ref={quickReplyBoxRef} className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
@@ -6137,7 +6198,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!onNavigatePrev}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 transition-colors duration-150",
|
||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.previous')}
|
||||
@@ -6148,7 +6209,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{isDraft && onEditDraft ? (
|
||||
<button
|
||||
onClick={() => onEditDraft()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||
aria-label={t('tooltips.edit_draft')}
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
@@ -6158,21 +6219,21 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
<>
|
||||
<button
|
||||
onClick={() => onReply?.()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onReplyAll}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<ReplyAll className="w-5 h-5" />
|
||||
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<Forward className="w-5 h-5" />
|
||||
<span className="text-[0.625rem] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
|
||||
@@ -6182,7 +6243,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
onClick={onNavigateNext}
|
||||
disabled={!onNavigateNext}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] min-w-0 transition-colors duration-150",
|
||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.next')}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { iconForName } from '@/components/icons';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isFilterEmpty } from '@/lib/jmap/search-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ export function useMessageListTabsVisible(): boolean {
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const selectedKeyword = useEmailStore((s) => s.selectedKeyword);
|
||||
const searchQuery = useEmailStore((s) => s.searchQuery);
|
||||
const filtersEmpty = useEmailStore((s) => isFilterEmpty(s.searchFilters));
|
||||
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
|
||||
const role = mailboxes.find((mb) => mb.id === selectedMailbox)?.role?.toLowerCase() ?? null;
|
||||
// Tabs only make sense on a plain mailbox view: tag views, searches and
|
||||
@@ -40,6 +42,7 @@ export function useMessageListTabsVisible(): boolean {
|
||||
mailboxRoles.includes(role) &&
|
||||
!selectedKeyword &&
|
||||
!searchQuery &&
|
||||
filtersEmpty &&
|
||||
!isUnifiedView
|
||||
);
|
||||
}
|
||||
@@ -96,6 +99,8 @@ export function MessageListTabs({ panelId }: { panelId: string }) {
|
||||
const handleSelect = (tabId: string) => {
|
||||
if (tabId === activeTabId) return;
|
||||
useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox);
|
||||
// The other tab lists other mail; ticks here would act on rows out of sight.
|
||||
useEmailStore.getState().clearSelection();
|
||||
if (client) void useEmailStore.getState().fetchEmails(client);
|
||||
};
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ export function MvComposeWindow({ children }: { children: ReactNode }) {
|
||||
"fixed z-40 flex flex-col overflow-hidden bg-background shadow-[0_8px_10px_1px_rgb(0_0_0/0.14),0_3px_14px_2px_rgb(0_0_0/0.12),0_5px_5px_-3px_rgb(0_0_0/0.2)]",
|
||||
maximized
|
||||
? "inset-x-[8vw] inset-y-[6vh] rounded-lg"
|
||||
: "bottom-0 end-4 w-[min(560px,calc(100vw-2rem))] rounded-t-lg",
|
||||
minimized && "h-10 w-[min(280px,calc(100vw-2rem))]",
|
||||
: "bottom-0 end-[var(--mv-compose-end,1rem)] w-[min(560px,calc(100vw-var(--mv-compose-end,1rem)-1rem))] rounded-t-lg",
|
||||
minimized && "h-10 w-[min(280px,calc(100vw-var(--mv-compose-end,1rem)-1rem))]",
|
||||
state === "normal" && "h-[min(600px,calc(100dvh-5rem))]",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { iconForName, X } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
activeListFilterCondition,
|
||||
activeListFilterKeys,
|
||||
listFilterApplies,
|
||||
listFilterRevision,
|
||||
@@ -16,6 +17,15 @@ import type { MailAccountKind } from "@/lib/plugin-sandbox/account-kind";
|
||||
|
||||
const NONE = new Set<string>();
|
||||
|
||||
/** Whether a plugin's filter is narrowing this folder's list right now. */
|
||||
export function useFolderListFiltered(mailboxRole: string | null, accountKind: MailAccountKind | null): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeListFilters,
|
||||
() => activeListFilterCondition({ role: mailboxRole, accountKind }) !== null,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyword filters plugins offer for this folder (api.ui.setListFilters),
|
||||
* as chips in the list header. A chip turns its filter on; an active chip
|
||||
|
||||
@@ -124,7 +124,11 @@ export function ProofreadingStatus({ proofreading, plainTextMode }: Props) {
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
{/* In a narrow composer the words run over Discard; the count still shows. */}
|
||||
<span className="hidden whitespace-nowrap @[40rem]/composer-bar:inline">{label}</span>
|
||||
{status.on && !status.paused && !status.trouble && status.count > 0 && (
|
||||
<span className="tabular-nums @[40rem]/composer-bar:hidden">{status.count}</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
|
||||
@@ -141,10 +141,12 @@ export function createTextCheckExtension(options: () => TextCheckOptions) {
|
||||
const k = (d.spec as IssueSpec).key;
|
||||
return k === active || k === value.active;
|
||||
});
|
||||
decos = decos.remove(redraw).add(state.doc, redraw.map((d) => {
|
||||
// Before remove(), which empties the array it is given.
|
||||
const redrawn = redraw.map((d) => {
|
||||
const spec = d.spec as IssueSpec;
|
||||
return decorationFor({ key: spec.key, kind: spec.kind, text: spec.text, from: d.from, to: d.to }, spec.key === active);
|
||||
}));
|
||||
});
|
||||
decos = decos.remove(redraw).add(state.doc, redrawn);
|
||||
}
|
||||
return { decos, active };
|
||||
},
|
||||
|
||||
@@ -174,6 +174,9 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p
|
||||
ta.addEventListener("paste", now);
|
||||
ta.addEventListener("blur", now);
|
||||
proofreader.edited("now");
|
||||
// The text is the same, so no new answer will say so: read the issues
|
||||
// again now that the textarea is there.
|
||||
bump();
|
||||
return () => {
|
||||
ta.removeEventListener("input", typing);
|
||||
ta.removeEventListener("paste", now);
|
||||
@@ -188,6 +191,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p
|
||||
dom.addEventListener("paste", now);
|
||||
editor.on("blur", now);
|
||||
proofreader.edited("now");
|
||||
bump();
|
||||
return () => {
|
||||
editor.off("update", typing);
|
||||
dom.removeEventListener("paste", now);
|
||||
|
||||
@@ -308,7 +308,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
|
||||
<div className={cn(
|
||||
"grid",
|
||||
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
|
||||
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] gap-2",
|
||||
)}>
|
||||
{shown.map((attachment) => (
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1" onContextMenu={desktopMenu ? (event) => menu.openContextMenu(event, attachment) : undefined}>
|
||||
|
||||
@@ -276,7 +276,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
ref={triggerRef}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words",
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words text-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useLayoutEffect, useState, useRef } from "react";
|
||||
import { popoverShift } from "@/lib/popover-shift";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEditor, useEditorState, EditorContent, type Editor } from "@tiptap/react";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
@@ -152,7 +153,7 @@ interface RichTextEditorProps {
|
||||
export function editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle, spellcheck = true, lang }: Pick<RichTextEditorProps, 'ariaLabel' | 'ariaInvalid' | 'ariaDescribedBy' | 'defaultTextStyle' | 'spellcheck' | 'lang'>): Record<string, string> {
|
||||
const style = defaultTextStyle ? composeTextStyleCss(defaultTextStyle) : "";
|
||||
return {
|
||||
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||
class: "tiptap focus-inset min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||
...(style ? { style } : {}),
|
||||
role: "textbox",
|
||||
"aria-multiline": "true",
|
||||
@@ -239,6 +240,19 @@ function ToolbarPopover({
|
||||
(checked ?? list[0])?.focus();
|
||||
}, []);
|
||||
|
||||
// Anchored to its button, it would run off a narrow composer's edge.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
const toolbar = el?.closest('[role="toolbar"]');
|
||||
if (!el || !toolbar) return;
|
||||
const box = toolbar.getBoundingClientRect();
|
||||
const shift = popoverShift(el.getBoundingClientRect(), {
|
||||
left: Math.max(box.left, 0),
|
||||
right: Math.min(box.right, window.innerWidth),
|
||||
});
|
||||
if (shift !== 0) el.style.transform = `translateX(${shift}px)`;
|
||||
}, []);
|
||||
|
||||
const close = (returnFocus: boolean) => {
|
||||
onClose();
|
||||
if (returnFocus) triggerRef.current?.focus();
|
||||
|
||||
@@ -266,7 +266,7 @@ export function ThreadConversationView({
|
||||
<h2 className="font-semibold text-foreground break-words line-clamp-2" dir="auto" title={thread.latestEmail.subject || undefined}>
|
||||
{thread.latestEmail.subject || t("email_viewer.no_subject")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="truncate text-xs sm:text-sm text-muted-foreground">
|
||||
{t("threads.messages_count", { count: emails.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +216,7 @@ function MvRowLead({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="group/check -ms-3 flex items-start self-stretch ps-3 pe-0.5"
|
||||
>
|
||||
<span className="flex h-10 items-center">
|
||||
<span className="flex h-[var(--list-item-height)] items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"p-1 rounded-full transition-colors group-hover/check:bg-foreground/10",
|
||||
@@ -232,7 +232,7 @@ function MvRowLead({
|
||||
</span>
|
||||
</button>
|
||||
{onToggleStar && (
|
||||
<span className="flex h-10 items-center">
|
||||
<span className="flex h-[var(--list-item-height)] items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={starLabel}
|
||||
@@ -310,10 +310,10 @@ function describeAccount(label: string | undefined, account: AccountEntry | unde
|
||||
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
|
||||
* from different folders. `email.sourceFolder` is stamped at fetch time.
|
||||
*/
|
||||
function SourceFolderTag({ name }: { name: string }) {
|
||||
function SourceFolderTag({ name, tight }: { name: string; tight?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground"
|
||||
className={cn("inline-flex shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground", tight ? "max-w-[4.5rem]" : "max-w-[8rem]")}
|
||||
title={name}
|
||||
>
|
||||
<Folder className="h-3 w-3 shrink-0" />
|
||||
@@ -451,7 +451,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const formatAddress = useOwnDomainAddress();
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
// The tint replaces the dot, and the dot can be switched off on its own (#912).
|
||||
@@ -646,7 +646,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onOpenMenu: onContextMenu ? handleContextMenu : undefined,
|
||||
})}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
"@container/mail-row relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
mvMobileRow && "border-b-transparent",
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
@@ -707,7 +707,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
<div
|
||||
role="gridcell"
|
||||
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', swipeEnabled && 'relative z-10 bg-inherit')}
|
||||
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-[var(--list-item-height)]', swipeEnabled && 'relative z-10 bg-inherit')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
|
||||
>
|
||||
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */}
|
||||
@@ -772,7 +772,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
<div className="flex-1 min-w-0">
|
||||
{oneLineRow ? (
|
||||
<>
|
||||
<div className={cn("flex items-center justify-between gap-3", mvRow && "min-h-10")}>
|
||||
<div className={cn("flex items-center justify-between gap-3", mvRow && "min-h-[var(--list-item-height)]")}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* In columns the dot sits inside the From cell, so a row
|
||||
with it starts its columns where the header does. */}
|
||||
@@ -809,7 +809,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
) : (sender?.name || (sender?.email && formatAddress(sender.email)) || tUnknown)}
|
||||
</span>
|
||||
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
|
||||
<div className={cn("flex min-w-0 flex-1 items-center overflow-hidden text-sm", mvRow ? "gap-0" : "gap-2")}>
|
||||
{tagIds.length > 0 && (
|
||||
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
|
||||
{tagIds.map((id) => (
|
||||
@@ -845,7 +845,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
|
||||
@@ -894,7 +894,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
@@ -923,7 +923,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
@@ -1181,7 +1181,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const tagScope = useEmailTagScope(latestEmail);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat(tagScope);
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const { variant: tagVariant, placement: tagPlacement, tight: senderLineTight } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
|
||||
// The tint replaces the dot, and the dot can be switched off on its own (#912).
|
||||
@@ -1354,7 +1354,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
data-mv-row={mvRow && !rowTint ? "" : undefined}
|
||||
data-checked={isChecked ? "" : undefined}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
"@container/mail-row relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
@@ -1411,7 +1411,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
<div
|
||||
role="gridcell"
|
||||
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', threadSwipeEnabled && 'relative z-10 bg-inherit')}
|
||||
className={cn('px-3', showRowActionButton && onContextMenu && 'pe-12', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-[var(--list-item-height)]', threadSwipeEnabled && 'relative z-10 bg-inherit')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: threadSwipeEnabled && threadOffsetX ? `translateX(${threadOffsetX}px)` : undefined, transition: threadSwipeEnabled && threadOffsetX === 0 ? 'transform 200ms ease-out' : undefined }}
|
||||
>
|
||||
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */}
|
||||
@@ -1503,7 +1503,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
<div className="flex-1 min-w-0">
|
||||
{oneLineRow ? (
|
||||
<>
|
||||
<div className={cn("flex items-center justify-between gap-3", mvRow && "min-h-10")}>
|
||||
<div className={cn("flex items-center justify-between gap-3", mvRow && "min-h-[var(--list-item-height)]")}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{!columns && isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
|
||||
<span
|
||||
@@ -1574,7 +1574,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
|
||||
<div className={cn("flex min-w-0 flex-1 items-center overflow-hidden text-sm", mvRow ? "gap-0" : "gap-2")}>
|
||||
{((!mvRow && !columns) || tagIds.length > 0) && (
|
||||
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
|
||||
{!mvRow && !columns && (
|
||||
@@ -1616,7 +1616,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
|
||||
@@ -1674,7 +1674,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} scope={tagScope} />
|
||||
<TagBadge key={id} tagId={id} variant={senderLineTight && (showSourceFolder || tagIds.length > 1) ? 'dot' : tagVariant} scope={tagScope} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -1702,7 +1702,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} tight={senderLineTight} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[0.6875rem] font-medium tabular-nums text-sky-700 dark:text-sky-300"
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TrashRecoveryLink({ mailbox, notice }: { mailbox: Mailbox | unde
|
||||
{notice ? (
|
||||
<span className="min-w-0" data-testid="retention-notice">{notice}</span>
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{t("trash_hint")}</span>
|
||||
<span className="min-w-0">{t("trash_hint")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1332,7 +1332,7 @@ export function FileBrowser({
|
||||
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
className="relative flex-1 min-w-0 overflow-y-auto"
|
||||
className="relative flex-1 min-w-0 overflow-y-auto max-md:pb-20"
|
||||
onMouseDown={handleMarqueeMouseDown}
|
||||
onContextMenu={(e) => {
|
||||
if (accountPickerMode || (e.target as HTMLElement).closest("[data-resource]")) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
|
||||
@@ -12,6 +12,7 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ToolbarButton, ToolbarSearch, TOOLBAR_ACTIVE } from "@/components/ui/toolbar";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
@@ -123,6 +124,9 @@ export function FilesHeader({
|
||||
const overflowMenu = useContextMenu<Crumb[]>();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [dropCrumb, setDropCrumb] = useState<string | null>(null);
|
||||
// A phone's row has no room for the path next to every tool: sort, view and details go in the ⋮.
|
||||
const narrow = useMediaQuery("(max-width: 30rem)") && compact;
|
||||
const toolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headerClass = "flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0";
|
||||
// The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6).
|
||||
@@ -165,23 +169,30 @@ export function FilesHeader({
|
||||
)}
|
||||
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
|
||||
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
|
||||
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
|
||||
{!compact && <ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />}
|
||||
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
|
||||
{/* Outside the scrolling row, so Delete and the "more" button never scroll out of sight. */}
|
||||
{compact && !selection.trash && <ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />}
|
||||
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
|
||||
<div className="flex-1" />
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
{!narrow && <DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />}
|
||||
{toggleSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Deep paths keep the root and the last folders; the rest go in a menu.
|
||||
const hidden = crumbs.length > MAX_VISIBLE_CRUMBS + 1 ? crumbs.slice(1, crumbs.length - MAX_VISIBLE_CRUMBS + 1) : [];
|
||||
const visible = hidden.length > 0 ? [crumbs[0], ...crumbs.slice(crumbs.length - MAX_VISIBLE_CRUMBS + 1)] : crumbs;
|
||||
// A phone only fits the folder you are in, with the way up in the menu.
|
||||
const onlyLast = narrow ? crumbs.length > 1 : compact && crumbs.length > 2;
|
||||
const hidden = onlyLast
|
||||
? crumbs.slice(0, -1)
|
||||
: crumbs.length > MAX_VISIBLE_CRUMBS + 1 ? crumbs.slice(1, crumbs.length - MAX_VISIBLE_CRUMBS + 1) : [];
|
||||
const visible = onlyLast
|
||||
? crumbs.slice(-1)
|
||||
: hidden.length > 0 ? [crumbs[0], ...crumbs.slice(crumbs.length - MAX_VISIBLE_CRUMBS + 1)] : crumbs;
|
||||
|
||||
const crumbDropProps = (crumb: Crumb, isLast: boolean) => isLast ? {} : {
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
@@ -202,12 +213,13 @@ export function FilesHeader({
|
||||
const renderCrumb = (crumb: Crumb, index: number) => {
|
||||
const isLast = crumb === crumbs[crumbs.length - 1];
|
||||
return (
|
||||
<li key={`${crumb.id ?? "root"}:${crumb.path}`} className="flex items-center min-w-0 shrink-0 last:shrink">
|
||||
<li key={`${crumb.id ?? "root"}:${crumb.path}`} className="flex items-center min-w-0 shrink first:shrink-0 last:shrink-[0.25]">
|
||||
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateCrumb(crumb)}
|
||||
aria-current={isLast ? "page" : undefined}
|
||||
title={crumb.name}
|
||||
className={cn(
|
||||
"px-1.5 py-1 rounded-md transition-colors truncate max-w-[16rem]",
|
||||
isLast ? "font-semibold text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
@@ -269,8 +281,8 @@ export function FilesHeader({
|
||||
|
||||
<nav aria-label={t("breadcrumbs")} className="flex-1 min-w-0">
|
||||
<ol className="flex items-center text-sm min-w-0">
|
||||
{visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
|
||||
{accountLabel && (
|
||||
{!onlyLast && visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
|
||||
{accountLabel && !onlyLast && (
|
||||
<li className="flex items-center min-w-0 shrink-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
|
||||
<button
|
||||
@@ -284,7 +296,7 @@ export function FilesHeader({
|
||||
)}
|
||||
{hidden.length > 0 && (
|
||||
<li className="flex items-center shrink-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
|
||||
{!onlyLast && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />}
|
||||
<button
|
||||
type="button"
|
||||
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
@@ -296,12 +308,12 @@ export function FilesHeader({
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{visible.slice(1).map((c, i) => renderCrumb(c, i + 1))}
|
||||
{(onlyLast ? visible : visible.slice(1)).map((c, i) => renderCrumb(c, i + 1))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{!toolsHidden && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div ref={toolsRef} className="flex items-center gap-1 shrink-0">
|
||||
{emptyTrash && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -326,7 +338,7 @@ export function FilesHeader({
|
||||
<Search />
|
||||
</ToolbarButton>
|
||||
) : searchField}
|
||||
<ToolbarButton
|
||||
{!narrow && <ToolbarButton
|
||||
label={t("sort_by")}
|
||||
active={sortMenu.contextMenu.isOpen}
|
||||
aria-haspopup="menu"
|
||||
@@ -338,8 +350,8 @@ export function FilesHeader({
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown />
|
||||
</ToolbarButton>
|
||||
<div className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0" role="radiogroup" aria-label={t("view_mode")}>
|
||||
</ToolbarButton>}
|
||||
{!narrow && <div className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0" role="radiogroup" aria-label={t("view_mode")}>
|
||||
{([["list", LayoutList, t("list_view")], ["grid", LayoutGrid, t("grid_view")]] as const).map(([mode, Icon, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
@@ -359,19 +371,26 @@ export function FilesHeader({
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
{!compact && (
|
||||
<ToolbarButton label={t("refresh")} onClick={onRefresh} disabled={isLoading}>
|
||||
<RefreshCw className={cn(isLoading && "animate-spin [animation-direction:reverse]")} />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
{!narrow && <DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />}
|
||||
{toggleSlot}
|
||||
<PhoneHeaderActions
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
// The phone header has no room for Refresh as a button.
|
||||
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
|
||||
<>
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
|
||||
{narrow && (viewMode === "list"
|
||||
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
|
||||
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
|
||||
{narrow && <ContextMenuItem icon={PanelRight} label={t("details")} onClick={() => { close(); onToggleDetails(); }} />}
|
||||
{/* The phone header has no room for Refresh as a button. */}
|
||||
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +60,8 @@ vi.mock('@/components/pro/pro-email-tab-body', () => ({
|
||||
}));
|
||||
|
||||
import { SearchTabBody } from '../search-tab-body';
|
||||
import { openHit } from '@/lib/global-search/open-hit';
|
||||
import { PaneSizeContext } from '@/hooks/use-pane-size';
|
||||
|
||||
/** Renders the tab the way the Pro page does: data flows from the tab store. */
|
||||
function Harness({ tabId }: { tabId: string }) {
|
||||
@@ -123,3 +125,29 @@ describe('SearchTabBody scope switching', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchTabBody in a narrow pane', () => {
|
||||
beforeEach(() => {
|
||||
resetTabs();
|
||||
vi.mocked(openHit).mockClear();
|
||||
});
|
||||
|
||||
const renderInPane = async (width: number) => {
|
||||
const tabId = useProTabStore.getState().openSearchTab({ query: 'Ann', scope: 'all', accountId: null, title: 'Ann' });
|
||||
render(<PaneSizeContext.Provider value={width}><Harness tabId={tabId} /></PaneSizeContext.Provider>);
|
||||
await waitFor(() => expect(rowCount('mail')).toBe(1), { timeout: 5000 });
|
||||
return document.querySelector<HTMLElement>('[data-hit-kind="mail"]')!;
|
||||
};
|
||||
|
||||
it('opens a hit on click where no preview fits', async () => {
|
||||
const row = await renderInPane(500);
|
||||
fireEvent.click(row);
|
||||
expect(openHit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('only selects it where the preview shows', async () => {
|
||||
const row = await renderInPane(1100);
|
||||
fireEvent.click(row);
|
||||
expect(openHit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Search } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useGlobalSearch } from "@/hooks/use-global-search";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { openHit } from "@/lib/global-search/open-hit";
|
||||
import type { SearchScope } from "@/lib/global-search/query-parser";
|
||||
import { hitKey, SEARCH_KINDS, type GlobalSearchHit, type SearchKind } from "@/lib/global-search/types";
|
||||
@@ -13,9 +14,14 @@ import { useSearchHistoryStore } from "@/stores/search-history-store";
|
||||
import { SearchFacets } from "@/components/global-search/search-facets";
|
||||
import { SearchPreview } from "@/components/global-search/search-preview";
|
||||
import { SearchResultList } from "@/components/global-search/search-result-list";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEARCH_TAB_REMOTE_LIMIT = 100;
|
||||
export const SEARCH_TAB_LOCAL_LIMIT = 50;
|
||||
/** Facets, a 384 px list and a preview need a pane this wide (Tailwind's lg). */
|
||||
const PREVIEW_MIN_PANE = 1024;
|
||||
/** Below this (a split pane) the facets narrow so the list keeps room for its rows. */
|
||||
const WIDE_FACETS_MIN_PANE = 768;
|
||||
|
||||
export interface SearchTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -89,9 +95,15 @@ export function SearchTabBody({ tabId, data }: SearchTabBodyProps) {
|
||||
const setAccountId = (accountId: string | null) => updateSearchTab(tabId, { accountId });
|
||||
const handleOpen = (hit: GlobalSearchHit) => { void openHit(hit, { proShell: true }); };
|
||||
|
||||
// The tab lives in a pane, which a split can make far narrower than the
|
||||
// window, so the pane's width decides and not a viewport breakpoint.
|
||||
const paneWidth = usePaneSize();
|
||||
const showPreview = paneWidth === null || paneWidth >= PREVIEW_MIN_PANE;
|
||||
const wideFacets = paneWidth === null || paneWidth >= WIDE_FACETS_MIN_PANE;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full bg-background">
|
||||
<aside className="w-56 shrink-0 bg-secondary border-e border-border overflow-y-auto">
|
||||
<aside className={cn("shrink-0 bg-secondary border-e border-border overflow-y-auto", wideFacets ? "w-56" : "w-40")}>
|
||||
<SearchFacets
|
||||
scope={data.scope}
|
||||
onScopeChange={setScope}
|
||||
@@ -105,7 +117,7 @@ export function SearchTabBody({ tabId, data }: SearchTabBodyProps) {
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="flex-1 lg:flex-none lg:w-96 min-w-0 flex flex-col border-e border-border">
|
||||
<section className={cn("min-w-0 flex flex-col", showPreview ? "w-96 flex-none border-e border-border" : "flex-1")}>
|
||||
<div className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<Search className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
@@ -126,7 +138,8 @@ export function SearchTabBody({ tabId, data }: SearchTabBodyProps) {
|
||||
kinds={kinds}
|
||||
flat={flat}
|
||||
selectedKey={selected ? hitKey(selected) : null}
|
||||
onSelect={setSelected}
|
||||
// No preview to fill: a click opens the hit, as its Open button would.
|
||||
onSelect={showPreview ? setSelected : handleOpen}
|
||||
onOpen={handleOpen}
|
||||
onLoadMoreMail={loadMoreMail}
|
||||
/>
|
||||
@@ -139,9 +152,11 @@ export function SearchTabBody({ tabId, data }: SearchTabBodyProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="hidden lg:block flex-1 min-w-0 overflow-y-auto">
|
||||
<SearchPreview hit={selected} onOpen={handleOpen} onClose={() => setSelected(null)} />
|
||||
</aside>
|
||||
{showPreview && (
|
||||
<aside className="flex-1 min-w-0 overflow-y-auto">
|
||||
<SearchPreview hit={selected} onOpen={handleOpen} onClose={() => setSelected(null)} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { SubAddressHelper } from '../sub-address-helper';
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
function open() {
|
||||
render(<SubAddressHelper baseEmail="nora@example.com" recipientEmails={[]} onSelectTag={() => {}} />);
|
||||
fireEvent.click(screen.getByTitle('button_tooltip'));
|
||||
return screen.getByRole('dialog', { name: 'popover_title' });
|
||||
}
|
||||
|
||||
describe('SubAddressHelper', () => {
|
||||
it('closes on Escape', () => {
|
||||
const dialog = open();
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps that Escape from what is around it, so the composer stays open', () => {
|
||||
const composerKey = vi.fn();
|
||||
render(
|
||||
<div onKeyDown={composerKey}>
|
||||
<SubAddressHelper baseEmail="nora@example.com" recipientEmails={[]} onSelectTag={() => {}} />
|
||||
</div>,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle('button_tooltip'));
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: 'popover_title' }), { key: 'Escape' });
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(composerKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves back on screen when its button is near the left edge', () => {
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: -80, right: 240 } as DOMRect);
|
||||
const dialog = open();
|
||||
expect(dialog.style.translate).toBe('88px');
|
||||
rect.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -272,8 +272,9 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 sm:flex-nowrap">
|
||||
{/* On a phone the actions go under the name instead of squeezing it. */}
|
||||
<div className="flex-1 min-w-0 max-sm:basis-full">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground truncate" title={identity.name || toUnicodeEmail(identity.email)} dir="auto">
|
||||
{identity.name || toUnicodeEmail(identity.email)}
|
||||
@@ -312,7 +313,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 max-sm:ms-auto">
|
||||
{identities[0]?.id !== identity.id && identities.length > 1 && (
|
||||
<IconButton
|
||||
label={t('set_as_primary')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
|
||||
import { popoverShift } from '@/lib/popover-shift';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Plus, Tag, X } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -65,6 +66,14 @@ export function SubAddressHelper({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Anchored to its button, it would run off the left of a phone's screen.
|
||||
useLayoutEffect(() => {
|
||||
const el = popoverRef.current;
|
||||
if (!isOpen || !el) return;
|
||||
const shift = popoverShift(el.getBoundingClientRect(), { left: 0, right: window.innerWidth });
|
||||
el.style.translate = shift !== 0 ? `${shift}px` : '';
|
||||
}, [isOpen]);
|
||||
|
||||
const handleTagChange = (value: string) => {
|
||||
setTag(value);
|
||||
const errorCode = getTagValidationError(value);
|
||||
@@ -136,10 +145,18 @@ export function SubAddressHelper({
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="dialog"
|
||||
aria-label={t('popover_title')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'absolute top-full end-0 mt-1 z-50',
|
||||
'bg-background border border-border rounded-lg shadow-lg',
|
||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
'w-80 max-w-[calc(100vw-1rem)] p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -53,6 +53,21 @@ describe.each(['vertical', 'horizontal'] as const)('navigation rail (%s)', (orie
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone tab bar with plugin pages', () => {
|
||||
it('keeps Settings out of the part that scrolls and scrolls to the open page', () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
register();
|
||||
const { container } = render(<NavigationRail orientation="horizontal" />);
|
||||
|
||||
const scroller = container.querySelector('[data-mobile-navigation-scroll]')!;
|
||||
expect(scroller.querySelector('a[href="/apps/kanban/board"]')).not.toBeNull();
|
||||
const settings = container.querySelector('a[href="/settings"]')!;
|
||||
expect(scroller.contains(settings)).toBe(false);
|
||||
expect(scrollIntoView.mock.instances[0]).toBe(scroller.querySelector('[aria-current="page"]'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mountain View app grid', () => {
|
||||
it('offers plugin pages next to the built-in apps', () => {
|
||||
register();
|
||||
@@ -65,3 +80,27 @@ describe('Mountain View app grid', () => {
|
||||
expect(screen.getByText('Stats')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabBarItemWidth', () => {
|
||||
it('leaves entries alone when they all fit', async () => {
|
||||
const { tabBarItemWidth } = await import('../navigation-rail');
|
||||
expect(tabBarItemWidth(338, 6)).toBeNull();
|
||||
expect(tabBarItemWidth(312, 6)).toBeNull();
|
||||
expect(tabBarItemWidth(0, 6)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows whole entries and half of the next when they do not fit', async () => {
|
||||
const { tabBarItemWidth } = await import('../navigation-rail');
|
||||
// 320 px phone with Settings pinned: 268 px for six entries.
|
||||
const width = tabBarItemWidth(268, 6)!;
|
||||
expect(width).toBeGreaterThanOrEqual(52);
|
||||
const visible = 268 / width;
|
||||
expect(visible - Math.floor(visible)).toBeCloseTo(0.5);
|
||||
// The same at any width that overflows.
|
||||
for (const bar of [120, 200, 268, 300, 311]) {
|
||||
const w = tabBarItemWidth(bar, 6)!;
|
||||
expect(w).toBeGreaterThanOrEqual(52);
|
||||
expect(bar / w - Math.floor(bar / w)).toBeCloseTo(0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
@@ -51,6 +51,24 @@ describe('Mountain View rail peek', () => {
|
||||
|
||||
const peek = (container: HTMLElement) => container.querySelector('[data-mv-rail-peek]');
|
||||
|
||||
it('shows folder names in the drawer of a narrow window, even when collapsed on a wider one', () => {
|
||||
const matchMedia = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes('64rem'), media: query, addEventListener() {}, removeEventListener() {},
|
||||
})) as never;
|
||||
try {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
expect(screen.getByText('News')).toBeTruthy();
|
||||
} finally {
|
||||
window.matchMedia = matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the icon rail on a wide window', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
expect(screen.queryByText('News')).toBeNull();
|
||||
});
|
||||
|
||||
it('peeks on hover by default', () => {
|
||||
const { container } = render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" mvShell />);
|
||||
fire.mouseEnter(container.firstElementChild!);
|
||||
|
||||
@@ -577,7 +577,7 @@ export function AccountSwitcher({
|
||||
className="w-full flex items-center gap-4 px-5 py-3 text-sm text-start hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-5 h-5 text-muted-foreground" />
|
||||
<LogOut className="w-5 h-5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t("sign_out_of", { account: displayEmail })}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -222,9 +222,11 @@ export function MvTopBar({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search: left-anchored and capped, not centred */}
|
||||
{/* Search: left-anchored and capped, not centred. It takes the free width
|
||||
before the spacer gets any; shared equally, it stayed half as wide as
|
||||
it could be, which at large text left no room for its own icons. */}
|
||||
{search && (
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 min-w-0 max-w-[720px]">
|
||||
<div className="mv-topbar-search relative flex items-center gap-1 flex-1 grow-[9999] min-w-0 max-w-[720px]">
|
||||
<SearchBox
|
||||
value={search.query}
|
||||
onChange={search.onQueryChange}
|
||||
|
||||
@@ -45,6 +45,18 @@ interface NavItem {
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How wide a phone tab bar entry should be, or null when all of them fit at
|
||||
* the normal width. A bar that doesn't fit shows whole entries and half of
|
||||
* the next: half an entry says there is more to scroll to, a few cut-off
|
||||
* pixels only look broken.
|
||||
*/
|
||||
export function tabBarItemWidth(barWidth: number, count: number, minWidth = 52): number | null {
|
||||
if (!barWidth || count * minWidth <= barWidth) return null;
|
||||
const whole = Math.max(1, Math.floor(barWidth / minWidth - 0.5));
|
||||
return barWidth / (whole + 0.5);
|
||||
}
|
||||
|
||||
interface NavigationRailProps {
|
||||
orientation?: "vertical" | "horizontal";
|
||||
collapsed?: boolean;
|
||||
@@ -230,6 +242,20 @@ export function NavigationRail({
|
||||
// The phone tab bar: toasts sit above it.
|
||||
const tabBarRef = useBottomControls(orientation === "horizontal");
|
||||
const pathname = usePathname();
|
||||
// Each app draws its own tab bar, so its scroll position starts over: bring
|
||||
// the current entry back into view when it lies past the edge of a full bar.
|
||||
const tabBarScrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
tabBarScrollRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}, [pathname]);
|
||||
const [tabBarWidth, setTabBarWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const bar = tabBarScrollRef.current;
|
||||
if (!bar || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => setTabBarWidth(bar.clientWidth));
|
||||
observer.observe(bar);
|
||||
return () => observer.disconnect();
|
||||
}, [orientation]);
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
@@ -394,18 +420,34 @@ export function NavigationRail({
|
||||
};
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
// Settings stays outside the part that scrolls: plugin pages and apps fill
|
||||
// the bar, and the last entry must not end up off screen with nothing to
|
||||
// say there is more. Sized by item count so all entries are equally wide.
|
||||
const scrollingCount = visibleItems.length + pluginPages.length
|
||||
+ visibleSidebarApps.filter((app) => app.showOnMobile).length
|
||||
+ (isStalwartAdmin || adminActive ? 1 : 0);
|
||||
const itemWidth = tabBarItemWidth(tabBarWidth, scrollingCount);
|
||||
return (
|
||||
<nav
|
||||
ref={tabBarRef}
|
||||
data-mobile-navigation=""
|
||||
className={cn(
|
||||
"flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
"flex items-center bg-background border-t border-border shrink-0 pb-[calc(env(safe-area-inset-bottom)/2)]",
|
||||
mountainView && "min-h-16 border-t-0 bg-secondary shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
|
||||
className
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label={t("nav_label")}
|
||||
>
|
||||
<div
|
||||
ref={tabBarScrollRef}
|
||||
data-mobile-navigation-scroll=""
|
||||
className="flex items-center min-w-0 overflow-x-auto mobile-scroll-hidden"
|
||||
style={{
|
||||
flex: `${scrollingCount} 1 ${scrollingCount * 52}px`,
|
||||
...(itemWidth ? { "--tab-item-width": `${itemWidth}px` } as React.CSSProperties : {}),
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = getIsActive(item.href, item.id);
|
||||
const Icon = item.icon;
|
||||
@@ -416,7 +458,7 @@ export function NavigationRail({
|
||||
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-0.5 min-h-[44px] grow shrink-0 basis-[var(--tab-item-width,52px)]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
@@ -458,7 +500,7 @@ export function NavigationRail({
|
||||
data-plugin-page-link={`${page.pluginId}/${page.id}`}
|
||||
title={page.pluginName}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-0.5 min-h-[44px] grow shrink-0 basis-[var(--tab-item-width,52px)]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
@@ -497,7 +539,7 @@ export function NavigationRail({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-0.5 min-h-[44px] grow shrink-0 basis-[var(--tab-item-width,52px)]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
@@ -520,7 +562,7 @@ export function NavigationRail({
|
||||
<a
|
||||
href={`${getPathPrefix()}/admin`}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-0.5 min-h-[44px] grow shrink-0 basis-[var(--tab-item-width,52px)]",
|
||||
"transition-colors duration-150",
|
||||
adminActive ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
@@ -546,13 +588,15 @@ export function NavigationRail({
|
||||
</a>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={handleNavClick('settings')}
|
||||
data-tour="nav-settings"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-0.5 min-h-[44px] grow shrink-0 basis-[52px]",
|
||||
"transition-colors duration-150",
|
||||
isSettingsActive
|
||||
? "text-primary"
|
||||
@@ -577,7 +621,9 @@ export function NavigationRail({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full",
|
||||
// On a short window (a phone on its side) the whole rail scrolls: the
|
||||
// pinned group at the bottom left the apps room for two icons.
|
||||
"flex flex-col h-full scroll-hidden [@media(max-height:32rem)]:overflow-y-auto",
|
||||
collapsed ? "items-center" : "",
|
||||
className
|
||||
)}
|
||||
@@ -598,7 +644,7 @@ export function NavigationRail({
|
||||
<nav
|
||||
data-window-rail=""
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden [@media(max-height:32rem)]:flex-none [@media(max-height:32rem)]:overflow-visible",
|
||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||
)}
|
||||
role="navigation"
|
||||
|
||||
@@ -61,6 +61,7 @@ import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
@@ -1166,7 +1167,12 @@ function SidebarImpl({
|
||||
// list behind it.
|
||||
const [railHovered, setRailHovered] = useState(false);
|
||||
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
|
||||
const isCollapsed = sidebarCollapsed && !hoverExpanded;
|
||||
// The rail is a desktop thing. Below that the sidebar is a drawer, opened to
|
||||
// read the folder names, whatever the collapse button last said on a wider window.
|
||||
const paneWidth = usePaneSize();
|
||||
const narrowWindow = useMediaQuery('(width < 64rem)');
|
||||
const isDrawer = paneWidth !== null ? paneWidth < 1024 : narrowWindow;
|
||||
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
@@ -1960,13 +1966,13 @@ function SidebarImpl({
|
||||
data-tour="compose-button"
|
||||
className={cn(
|
||||
"flex items-center bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)] hover:shadow-md transition-shadow",
|
||||
isCollapsed ? "w-14 justify-center" : "gap-3 ps-4 pe-6"
|
||||
isCollapsed ? "w-14 justify-center" : "max-w-full gap-[min(0.75rem,12px)] ps-[min(1rem,16px)] pe-[min(1.5rem,24px)]"
|
||||
)}
|
||||
title={t("compose_hint")}
|
||||
aria-label={t("compose")}
|
||||
>
|
||||
<Pencil className="w-6 h-6 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="text-sm font-medium">{t("compose")}</span>}
|
||||
{!isCollapsed && <span className="min-w-0 truncate text-sm font-medium">{t("compose")}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { handOffComposeTab, useProTabStore } from '@/stores/pro-tab-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// A reply crossing between a Pro compose tab and the standard composer answers
|
||||
// the message it was started from. The message selected in the mail list can be
|
||||
// another one, and it must not get the $answered flag.
|
||||
|
||||
const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined }));
|
||||
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect, useRef, useState } = await import('react');
|
||||
return {
|
||||
EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: {
|
||||
initialData?: { subject?: string; mode?: string } | null;
|
||||
onSaveState: (data: { subject: string; mode?: string }) => void;
|
||||
onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise<unknown>;
|
||||
sourceEmailId?: string;
|
||||
}) => {
|
||||
seen.sourceEmailId = sourceEmailId;
|
||||
const [subject, setSubject] = useState(initialData?.subject ?? '');
|
||||
const initial = useRef(subject);
|
||||
const latest = useRef(subject);
|
||||
latest.current = subject;
|
||||
useEffect(() => () => {
|
||||
if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
<button onClick={() => void onSend({ to: ['a@example.org'], cc: [], bcc: [], subject, body: 'x' })}>stub send</button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => false,
|
||||
atLeast: (px: number) => `(width >= ${px}px)`,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox;
|
||||
const messageB = { id: 'b', threadId: 't-b', subject: 'B', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
|
||||
const SOURCE_OF_A = { clientAccountId: 'login-a', accountId: 'shared' };
|
||||
|
||||
let markEmailKeyword: ReturnType<typeof vi.fn>;
|
||||
|
||||
function handOffReply(composeData: { sourceEmailId?: string | null; sourceEmailSource?: typeof SOURCE_OF_A | null }) {
|
||||
useProTabStore.setState({
|
||||
tabs: [
|
||||
{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' },
|
||||
{
|
||||
id: 'c1', kind: 'compose', labelKey: '', closeable: true, paneId: 'main',
|
||||
composeData: { sessionId: 1, mode: 'reply', title: 'Re: A', initialData: { subject: 'Re: A', mode: 'reply' } as never, ...composeData },
|
||||
},
|
||||
],
|
||||
activeTabId: 'c1',
|
||||
} as never);
|
||||
handOffComposeTab('c1');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seen.sourceEmailId = undefined;
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never);
|
||||
markEmailKeyword = vi.fn().mockResolvedValue(undefined);
|
||||
useEmailStore.setState({
|
||||
emails: [messageB], selectedEmail: messageB, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
|
||||
sendEmail: vi.fn().mockResolvedValue({ scheduled: false }),
|
||||
refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
markEmailKeyword,
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('a reply handed from a Pro compose tab to the standard composer', () => {
|
||||
it('flags the message it answers, not the one selected in the list', async () => {
|
||||
handOffReply({ sourceEmailId: 'a', sourceEmailSource: SOURCE_OF_A });
|
||||
render(<MailApp />);
|
||||
await screen.findByLabelText('stub subject');
|
||||
expect(seen.sourceEmailId).toBe('a');
|
||||
|
||||
fireEvent.click(screen.getByText('stub send'));
|
||||
await waitFor(() => expect(markEmailKeyword).toHaveBeenCalled());
|
||||
expect(markEmailKeyword).toHaveBeenCalledTimes(1);
|
||||
expect(markEmailKeyword).toHaveBeenCalledWith(expect.anything(), 'a', '$answered', SOURCE_OF_A);
|
||||
});
|
||||
|
||||
it('flags nothing when the tab did not know its message', async () => {
|
||||
handOffReply({});
|
||||
render(<MailApp />);
|
||||
await screen.findByLabelText('stub subject');
|
||||
expect(seen.sourceEmailId).toBeUndefined();
|
||||
|
||||
fireEvent.click(screen.getByText('stub send'));
|
||||
await waitFor(() => expect(useEmailStore.getState().sendEmail).toHaveBeenCalled());
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
||||
expect(markEmailKeyword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes its message along when the window widens back into Pro', async () => {
|
||||
handOffReply({ sourceEmailId: 'a', sourceEmailSource: SOURCE_OF_A });
|
||||
const view = render(<MailApp />);
|
||||
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Re: A, typed' } });
|
||||
|
||||
useSettingsStore.setState({ proInterface: true } as never);
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
|
||||
view.unmount();
|
||||
|
||||
const tab = useProTabStore.getState().tabs.find((t) => t.kind === 'compose');
|
||||
expect(tab?.composeData?.initialData?.subject).toBe('Re: A, typed');
|
||||
expect(tab?.composeData?.sourceEmailId).toBe('a');
|
||||
expect(tab?.composeData?.sourceEmailSource).toEqual(SOURCE_OF_A);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// Mountain View draws the composer in a window, a card or the reading pane
|
||||
// depending on the width, and React mounts it anew in each. What was typed has
|
||||
// to come along, or a narrowed window silently empties the message.
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
// The real composer's contract: its state starts from initialData, and it hands
|
||||
// its edits to onSaveState as it unmounts, only if it has any, through the
|
||||
// handler of its first render. Text it started with is no edit.
|
||||
vi.mock('@/components/email/email-composer', async () => {
|
||||
const { useEffect, useRef, useState } = await import('react');
|
||||
return {
|
||||
EmailComposer: ({ initialData, onSaveState }: {
|
||||
initialData?: { subject?: string } | null;
|
||||
onSaveState: (data: { subject: string }) => void;
|
||||
}) => {
|
||||
const [subject, setSubject] = useState(initialData?.subject ?? '');
|
||||
const initial = useRef(subject);
|
||||
const latest = useRef(subject);
|
||||
latest.current = subject;
|
||||
useEffect(() => () => {
|
||||
if (latest.current !== initial.current) onSaveState({ subject: latest.current });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return <input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/search/mv-search-form', () => ({
|
||||
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'mountain-view', mailLayout: 'focus' } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('moving the Mountain View composer between layouts', () => {
|
||||
it('keeps what was typed when the window narrows past the desktop width and back', async () => {
|
||||
const { rerender } = render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Still here' } });
|
||||
|
||||
device.current = { isMobile: false, isTablet: true, isDesktop: false };
|
||||
rerender(<MailApp />);
|
||||
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
|
||||
|
||||
device.current = { isMobile: false, isTablet: false, isDesktop: true };
|
||||
rerender(<MailApp />);
|
||||
await waitFor(() => expect(screen.getByLabelText<HTMLInputElement>('stub subject').value).toBe('Still here'));
|
||||
});
|
||||
|
||||
const setWidth = (rerender: (ui: React.ReactElement) => void, width: 'desktop' | 'tablet') => {
|
||||
device.current = { isMobile: false, isTablet: width === 'tablet', isDesktop: width === 'desktop' };
|
||||
rerender(<MailApp />);
|
||||
};
|
||||
const subject = () => screen.getByLabelText<HTMLInputElement>('stub subject');
|
||||
|
||||
it('keeps text typed after the composer moved with nothing in it yet', async () => {
|
||||
const { rerender } = render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByLabelText('stub subject');
|
||||
|
||||
// Clean, so this move hands nothing over.
|
||||
setWidth(rerender, 'tablet');
|
||||
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Typed in the pane' } });
|
||||
|
||||
setWidth(rerender, 'desktop');
|
||||
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
|
||||
|
||||
setWidth(rerender, 'tablet');
|
||||
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
|
||||
});
|
||||
|
||||
it('keeps text typed after moving back and forth untouched', async () => {
|
||||
const { rerender } = render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'First' } });
|
||||
|
||||
setWidth(rerender, 'tablet');
|
||||
await waitFor(() => expect(subject().value).toBe('First'));
|
||||
setWidth(rerender, 'desktop');
|
||||
await waitFor(() => expect(subject().value).toBe('First'));
|
||||
fireEvent.change(subject(), { target: { value: 'First and more' } });
|
||||
|
||||
setWidth(rerender, 'tablet');
|
||||
await waitFor(() => expect(subject().value).toBe('First and more'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a composer opened from the phone and tablet drawer', () => {
|
||||
const openFromInsideDrawer = () => window.dispatchEvent(new CustomEvent('bulwark:mailto', {
|
||||
detail: { to: ['bob@example.org'], cc: [], bcc: [], subject: '', body: '' },
|
||||
cancelable: true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: true, isDesktop: false };
|
||||
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
|
||||
useUIStore.setState({ sidebarOpen: true });
|
||||
});
|
||||
|
||||
it('closes the drawer instead of leaving the composer under it', async () => {
|
||||
render(<MailApp />);
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
openFromInsideDrawer();
|
||||
await screen.findByLabelText('stub subject');
|
||||
await waitFor(() => expect(useUIStore.getState().sidebarOpen).toBe(false));
|
||||
});
|
||||
|
||||
it('lets the drawer open again over a composer that is already open', async () => {
|
||||
render(<MailApp />);
|
||||
openFromInsideDrawer();
|
||||
await screen.findByLabelText('stub subject');
|
||||
await waitFor(() => expect(useUIStore.getState().sidebarOpen).toBe(false));
|
||||
act(() => useUIStore.setState({ sidebarOpen: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(useUIStore.getState().sidebarOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -22,6 +22,7 @@ vi.mock('@/components/layout/mv-top-bar', () => ({
|
||||
<>
|
||||
<button onClick={() => search.onSubmit('invoice')}>stub search</button>
|
||||
<button onClick={() => search.onSelectContact({ email: 'bob@example.org' }, 'from')}>stub contact</button>
|
||||
<input aria-label="stub search field" data-search-input />
|
||||
</>
|
||||
),
|
||||
}));
|
||||
@@ -129,4 +130,14 @@ describe('searching in Mountain View while a message is open', () => {
|
||||
await waitFor(() => expect(advancedSearch).toHaveBeenCalled());
|
||||
expect(selectedId()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the message open for the / shortcut, whose field is in the top bar', async () => {
|
||||
render(<MailApp />);
|
||||
await screen.findByText('message viewer');
|
||||
act(() => { fireEvent.keyDown(window, { key: '/' }); });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('stub search field')));
|
||||
// Long enough for a go-back that runs after the field is focused.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
|
||||
expect(selectedId()).toBe('m1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { EmbeddedContext } from '@/hooks/use-is-embedded';
|
||||
import { ProTabFocusContext } from '@/hooks/use-pane-context';
|
||||
import { useProTabStore, type ProTabKind } from '@/stores/pro-tab-store';
|
||||
|
||||
// Under Pro the mail page stays mounted behind other tabs. Its shortcuts acted
|
||||
// from there: e on the Calendar tab archived the mail selected in the hidden
|
||||
// list.
|
||||
|
||||
type ShortcutOptions = { enabled?: boolean; handlers: Record<string, unknown> };
|
||||
const shortcuts = vi.hoisted(() => ({ calls: [] as ShortcutOptions[] }));
|
||||
vi.mock('@/hooks/use-keyboard-shortcuts', () => ({
|
||||
useKeyboardShortcuts: (options: ShortcutOptions) => { shortcuts.calls.push(options); },
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => true,
|
||||
atLeast: (px: number) => `(width >= ${px}px)`,
|
||||
}));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => null }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
|
||||
|
||||
beforeEach(() => {
|
||||
shortcuts.calls = [];
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false } as never);
|
||||
useEmailStore.setState({
|
||||
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** The enabled hook calls of the last render: MailApp makes two per render. */
|
||||
function activeShortcuts() {
|
||||
return shortcuts.calls.slice(-2).filter((c) => c.enabled);
|
||||
}
|
||||
|
||||
function renderPro(focused: boolean) {
|
||||
render(
|
||||
<EmbeddedContext.Provider value={true}>
|
||||
<ProTabFocusContext.Provider value={focused}>
|
||||
<MailApp />
|
||||
</ProTabFocusContext.Provider>
|
||||
</EmbeddedContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
function withTabInFront(kind: ProTabKind) {
|
||||
useProTabStore.setState({
|
||||
tabs: [
|
||||
{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' },
|
||||
{ id: 'front', kind, labelKey: kind, closeable: true, paneId: 'main' },
|
||||
],
|
||||
activeTabId: 'front', activeSplitTabId: null, focusedPaneId: 'main',
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('mail shortcuts under the Pro shell', () => {
|
||||
it.each(['files', 'settings', 'contacts', 'folder', 'search', 'email', 'compose'] as ProTabKind[])(
|
||||
'leaves the help and a new message on while a %s tab is in front, and nothing that acts on mail',
|
||||
(kind) => {
|
||||
withTabInFront(kind);
|
||||
renderPro(false);
|
||||
const active = activeShortcuts();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(Object.keys(active[0].handlers).sort()).toEqual(['onCompose', 'onShowHelp']);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves c to the Calendar tab, whose c is New event', () => {
|
||||
withTabInFront('calendar');
|
||||
renderPro(false);
|
||||
const active = activeShortcuts();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(Object.keys(active[0].handlers)).toEqual(['onShowHelp']);
|
||||
});
|
||||
|
||||
it('goes by the pane in focus when the Calendar tab is in the other one', () => {
|
||||
withTabInFront('calendar');
|
||||
useProTabStore.setState({
|
||||
tabs: [
|
||||
{ id: 'home-mail', kind: 'mail', labelKey: 'mail', closeable: false, paneId: 'main' },
|
||||
{ id: 'front', kind: 'calendar', labelKey: 'calendar', closeable: true, paneId: 'main' },
|
||||
{ id: 'files', kind: 'files', labelKey: 'files', closeable: true, paneId: 'split' },
|
||||
],
|
||||
activeSplitTabId: 'files', focusedPaneId: 'split',
|
||||
} as never);
|
||||
renderPro(false);
|
||||
expect(activeShortcuts()[0].handlers.onCompose).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('has the mail shortcuts on while the mail tab is in front', () => {
|
||||
renderPro(true);
|
||||
const active = activeShortcuts();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].handlers.onArchive).toBeTypeOf('function');
|
||||
expect(active[0].handlers.onDelete).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('has them on in the standard shell, where there are no tabs', () => {
|
||||
render(<MailApp />);
|
||||
const active = activeShortcuts();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].handlers.onArchive).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => <input aria-label="stub search field" data-search-input /> }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
@@ -273,6 +273,35 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
describe('A list collapsed under an open message (focused layout)', () => {
|
||||
const list = () => screen.getByText('message list').closest('section')!;
|
||||
|
||||
it('is out of the Tab order while the message is open', async () => {
|
||||
render(<MailApp />);
|
||||
await screen.findByText('stub archive', {}, { timeout: 5000 });
|
||||
expect(list()).toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('comes back for the / shortcut, whose search field lives in it', async () => {
|
||||
render(<MailApp />);
|
||||
await screen.findByText('stub archive', {}, { timeout: 5000 });
|
||||
act(() => { fireEvent.keyDown(window, { key: '/' }); });
|
||||
await waitFor(() => expect(selectedId()).toBeNull());
|
||||
await waitFor(() => expect(list()).not.toHaveAttribute('inert'));
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('stub search field')));
|
||||
});
|
||||
|
||||
it('keeps the selection for the / shortcut while a linked message is still arriving', async () => {
|
||||
// The mailboxes never arrive, so the link stays unapplied and the list collapsed for it.
|
||||
useEmailStore.setState({ fetchMailboxes: vi.fn(() => new Promise(() => {})) } as never);
|
||||
render(<MailApp linkSegments={['message', 'm2']} />);
|
||||
expect(list()).toHaveAttribute('inert');
|
||||
act(() => { fireEvent.keyDown(window, { key: '/' }); });
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
|
||||
expect(selectedId()).toBe('m1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('The e shortcut in Mountain View', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
|
||||
// A phone on its side: the bottom bar takes a sixth of the window, so it goes
|
||||
// over what fills the window. It stays wherever the list shows, or there is no
|
||||
// way left to the other apps.
|
||||
|
||||
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: true, isDesktop: false }, short: true }));
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => device.current,
|
||||
useMediaQuery: (query: string) => query.includes('max-height') && device.short,
|
||||
useIsDesktop: () => false,
|
||||
}));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav>tab bar</nav> }));
|
||||
vi.mock('@/components/layout/sidebar', () => ({
|
||||
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => <p>stub composer</p> }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
|
||||
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
|
||||
|
||||
const { MailApp } = await import('../mail-app');
|
||||
|
||||
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox;
|
||||
const message = { id: 'm1', threadId: 't-m1', subject: 'm1', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
|
||||
|
||||
beforeEach(() => {
|
||||
device.current = { isMobile: false, isTablet: true, isDesktop: false };
|
||||
device.short = true;
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
|
||||
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
|
||||
const client = {
|
||||
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
|
||||
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
|
||||
};
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
|
||||
getClientForAccount: () => client as never,
|
||||
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
|
||||
});
|
||||
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
|
||||
useUIStore.setState({ activeView: 'list', tabletListVisible: true, sidebarOpen: false });
|
||||
useEmailStore.setState({
|
||||
emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
|
||||
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
|
||||
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
|
||||
selectedEmailKeys: new Set(), unreadSection: null,
|
||||
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
|
||||
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
|
||||
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
|
||||
markAsRead: vi.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const tabBar = () => screen.queryByText('tab bar');
|
||||
|
||||
describe('the bottom bar in a short window', () => {
|
||||
it('stays with the list beside an open message', () => {
|
||||
useEmailStore.setState({ selectedEmail: message } as never);
|
||||
render(<MailApp />);
|
||||
expect(tabBar()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('goes when the message fills the window', () => {
|
||||
useUIStore.setState({ tabletListVisible: false });
|
||||
useEmailStore.setState({ selectedEmail: message } as never);
|
||||
render(<MailApp />);
|
||||
expect(tabBar()).toBeNull();
|
||||
});
|
||||
|
||||
it('goes over a composer, and comes back without one', async () => {
|
||||
render(<MailApp />);
|
||||
expect(tabBar()).not.toBeNull();
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(tabBar()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays in a tall window whatever is open', () => {
|
||||
device.short = false;
|
||||
useUIStore.setState({ tabletListVisible: false });
|
||||
useEmailStore.setState({ selectedEmail: message } as never);
|
||||
render(<MailApp />);
|
||||
expect(tabBar()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays on a phone\'s list with a message kept selected behind it', () => {
|
||||
device.current = { isMobile: true, isTablet: false, isDesktop: false };
|
||||
useEmailStore.setState({ selectedEmail: message } as never);
|
||||
render(<MailApp />);
|
||||
expect(tabBar()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,9 @@ vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({
|
||||
MobileHeader: ({ onOpenSearch }: { onOpenSearch?: () => void }) => <button onClick={onOpenSearch}>stub open search</button>,
|
||||
}));
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
@@ -103,3 +105,17 @@ describe('sending from a phone', () => {
|
||||
expect(listHidden()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the filter panel on a phone', () => {
|
||||
it('shows the results when Search is pressed: they are behind the full-screen panel', async () => {
|
||||
useEmailStore.setState({ isAdvancedSearchOpen: true, advancedSearch: vi.fn().mockResolvedValue(undefined) } as never);
|
||||
const { container } = render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub open search', {}, { timeout: 5000 }));
|
||||
expect(container.querySelector('.fixed.inset-0.z-50')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /search$/i }).find((b) => b.getAttribute('type') === 'submit' || /(^|\.)search$/.test(b.textContent ?? ''))!);
|
||||
|
||||
expect(container.querySelector('.fixed.inset-0.z-50')).toBeNull();
|
||||
expect(useEmailStore.getState().isAdvancedSearchOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -34,6 +34,25 @@ vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => nu
|
||||
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
|
||||
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
|
||||
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
|
||||
// As the real one, it marks the message it is given as read, mounted or not.
|
||||
vi.mock('@/components/email/email-viewer', async () => {
|
||||
const { useEffect } = await import('react');
|
||||
return {
|
||||
EmailViewer: ({ email, onMarkAsRead }: {
|
||||
email?: { id: string } | null;
|
||||
onMarkAsRead?: (id: string, read: boolean, opts?: { automatic?: boolean }) => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (email) onMarkAsRead?.(email.id, true, { automatic: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email?.id]);
|
||||
return <div>message viewer</div>;
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
|
||||
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
|
||||
@@ -45,8 +64,9 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
|
||||
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
|
||||
const device = vi.hoisted(() => ({ isMobile: true }));
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
|
||||
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
|
||||
useMediaQuery: () => false,
|
||||
useIsDesktop: () => false,
|
||||
}));
|
||||
@@ -88,6 +108,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
device.isMobile = true;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -102,6 +123,70 @@ describe('Mail after the tour on a phone', () => {
|
||||
expect(listHidden()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an open composer in view when the window narrows to a phone', async () => {
|
||||
device.isMobile = false;
|
||||
useUIStore.setState({ activeView: 'list' });
|
||||
const view = render(<MailApp />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
|
||||
await screen.findByText('stub composer', {}, { timeout: 5000 });
|
||||
expect(useUIStore.getState().activeView).toBe('list');
|
||||
|
||||
device.isMobile = true;
|
||||
view.rerender(<MailApp />);
|
||||
|
||||
expect(useUIStore.getState().activeView).toBe('viewer');
|
||||
});
|
||||
|
||||
const unread = (id: string) => ({
|
||||
id, threadId: `t-${id}`, subject: id, receivedAt: '2026-10-31T10:00:00Z', keywords: {}, mailboxIds: { inbox: true },
|
||||
}) as unknown as Email;
|
||||
|
||||
it('keeps an open message in view when the window narrows to a phone', () => {
|
||||
device.isMobile = false;
|
||||
const message = unread('m1');
|
||||
useEmailStore.setState({ emails: [message], selectedEmail: message, fetchEmailContent: vi.fn().mockResolvedValue(undefined) } as never);
|
||||
const view = render(<MailApp />);
|
||||
expect(useUIStore.getState().activeView).toBe('list');
|
||||
|
||||
device.isMobile = true;
|
||||
view.rerender(<MailApp />);
|
||||
|
||||
expect(useUIStore.getState().activeView).toBe('viewer');
|
||||
});
|
||||
|
||||
it('does not open a message left selected behind the list when Mail is entered again', () => {
|
||||
const message = unread('m1');
|
||||
useEmailStore.setState({ emails: [message], selectedEmail: message, fetchEmailContent: vi.fn().mockResolvedValue(undefined) } as never);
|
||||
render(<MailApp />);
|
||||
expect(useUIStore.getState().activeView).toBe('list');
|
||||
expect(listHidden()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the Mountain View cursor when the window narrows, unopened and still unread', async () => {
|
||||
device.isMobile = false;
|
||||
const first = unread('m1');
|
||||
const markAsRead = vi.fn().mockResolvedValue(undefined);
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'focus', singleKeyShortcuts: true, markAsReadDelay: 0 } as never);
|
||||
Object.assign(useAuthStore.getState().client as object, {
|
||||
getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: [threadId.slice(2)] })),
|
||||
getEmail: vi.fn(async (id: string) => ({ ...unread(id), bodyValues: {} })),
|
||||
});
|
||||
useEmailStore.setState({ emails: [first, unread('m2')], markAsRead } as never);
|
||||
const view = render(<MailApp />);
|
||||
|
||||
act(() => { fireEvent.keyDown(window, { key: 'j' }); });
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedEmail?.id).toBe('m1'));
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
|
||||
expect(markAsRead).not.toHaveBeenCalled();
|
||||
|
||||
device.isMobile = true;
|
||||
view.rerender(<MailApp />);
|
||||
|
||||
await waitFor(() => expect(useEmailStore.getState().selectedEmail).toBeNull());
|
||||
expect(useUIStore.getState().activeView).toBe('list');
|
||||
expect(markAsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the composer the tour opened when the tour ends', async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
|
||||
|
||||
+170
-27
@@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
|
||||
import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
|
||||
import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, type EmailWriteSource, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store";
|
||||
import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source";
|
||||
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
@@ -58,10 +58,10 @@ import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
|
||||
import { useOfflineStore } from "@/lib/offline/state";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useUIStore, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
|
||||
import { atLeast, useDeviceDetection, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
@@ -120,7 +120,7 @@ import { getDraftComposerBody } from '@/lib/email-body-selection';
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { takeHandedOffComposeDraft, useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailboxes";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { WopiEditor } from "@/components/files/wopi-editor";
|
||||
@@ -230,6 +230,7 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise<Mailbo
|
||||
* message itself has loaded, before showing it with a placeholder. (#1223)
|
||||
*/
|
||||
const MV_CONVERSATION_WAIT_MS = 800;
|
||||
const NO_EMAILS: Email[] = [];
|
||||
|
||||
/** Plugin buttons in the top bar are not about any one item. */
|
||||
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
|
||||
@@ -277,6 +278,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null);
|
||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
// Mountain View draws the composer in a window, a card or the reading pane
|
||||
// depending on the width, and React mounts it anew in each. The composer hands
|
||||
// its edits over as it unmounts, too late for the one already mounting there,
|
||||
// so that one is mounted once more with them.
|
||||
const [composerRemount, setComposerRemount] = useState(0);
|
||||
// A draft carried over from a Pro compose tab answers the message that tab was
|
||||
// opened from, whatever is selected here. Holds for the session it arrived in.
|
||||
const [handedOffSource, setHandedOffSource] = useState<{ session: number; emailId?: string; source?: EmailWriteSource } | null>(null);
|
||||
const composerSessionRef = useRef(composerSessionId);
|
||||
composerSessionRef.current = composerSessionId;
|
||||
const composerHostRef = useRef('pane');
|
||||
const showComposerRef = useRef(showComposer);
|
||||
showComposerRef.current = showComposer;
|
||||
// Fields a plugin filled in through onBeforeCompose for the composer about
|
||||
// to open. Dropped once the composer hands its own state back, so a
|
||||
// remount restores what the user wrote instead.
|
||||
@@ -301,6 +315,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Column resize state (disable transitions during drag)
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(0);
|
||||
const listPaneRef = useRef<HTMLElement>(null);
|
||||
// Mobile conversation view state
|
||||
const [conversationBlobSource, setConversationBlobSource] = useState<EmailBlobContext | undefined>();
|
||||
const conversationOpenSeqRef = useRef(0);
|
||||
@@ -392,6 +407,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Mobile/tablet responsive hooks
|
||||
const { isMobile, isTablet } = useDeviceDetection();
|
||||
const isShortWindow = useMediaQuery('(max-height: 32rem)');
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// A message or conversation link shows the reader from the first frame, as
|
||||
// the page skeleton draws it, in its loading state until the link has been
|
||||
@@ -454,11 +470,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// in that pane don't: coming back to Mail after the tour opened the
|
||||
// composer (or after leaving any open composer) showed only the empty
|
||||
// welcome pane, with no way back. With nothing to read, show the list.
|
||||
// A window narrowed to phone size with a message or the composer open keeps
|
||||
// showing it: on the list, the composer would be open with no way to reach it.
|
||||
// Only as it narrows: a phone's list can sit on a message kept selected behind
|
||||
// it, and coming back to Mail is no reason to open that again. A Mountain View
|
||||
// cursor is not an open message either.
|
||||
const wasMobileRef = useRef(isMobile);
|
||||
useEffect(() => {
|
||||
const narrowed = isMobile && !wasMobileRef.current;
|
||||
wasMobileRef.current = isMobile;
|
||||
if (!isMobile) return;
|
||||
if (useUIStore.getState().activeView === "viewer" && !useEmailStore.getState().selectedEmail) {
|
||||
setActiveView("list");
|
||||
}
|
||||
const hasContent = showComposer || (!!useEmailStore.getState().selectedEmail && !cursorOnly);
|
||||
const view = useUIStore.getState().activeView;
|
||||
if (view === "viewer" && !hasContent) setActiveView("list");
|
||||
else if (view === "list" && hasContent && narrowed) setActiveView("viewer");
|
||||
// Only when the device class changes; opening and closing set the view themselves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMobile, setActiveView]);
|
||||
const {
|
||||
emails,
|
||||
@@ -1129,10 +1156,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
if (isScheduledView) return;
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
const findSearchInput = () => document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const focusSearch = () => {
|
||||
const searchInput = findSearchInput();
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
};
|
||||
// The field can be in the list, which an open message has collapsed, and
|
||||
// that message has to close first. Mountain View's is in the top bar.
|
||||
if (findSearchInput()?.closest('[inert]')) {
|
||||
// A linked message still arriving holds the list collapsed: nothing to
|
||||
// go back to yet.
|
||||
if (deepLinkPane !== null) return;
|
||||
handleMobileBack();
|
||||
requestAnimationFrame(() => requestAnimationFrame(focusSearch));
|
||||
} else {
|
||||
focusSearch();
|
||||
}
|
||||
},
|
||||
onShowHelp: () => {
|
||||
@@ -1215,7 +1256,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled]);
|
||||
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
// Mountain View keeps the ticked rows while a message is read (#1231). The
|
||||
@@ -1224,8 +1265,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const openEmailInWindow = useOpenEmailInWindow();
|
||||
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
|
||||
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
// Under Pro this page stays mounted behind other tabs. Its mail shortcuts
|
||||
// are only for when it is in front: e on the Calendar tab archived the mail
|
||||
// selected here, out of sight. The help opens from any tab, and so does a new
|
||||
// message, which touches nothing out of sight, except on the Calendar tab: its
|
||||
// c is New event.
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const calendarTabInFront = useProTabStore((state) => {
|
||||
const id = state.focusedPaneId === 'split' ? state.activeSplitTabId : state.activeTabId;
|
||||
return state.tabs.find((tab) => tab.id === id)?.kind === 'calendar';
|
||||
});
|
||||
const mailShortcutsApply = !isEmbedded || isFocusedProTab;
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
enabled: isAuthenticated && !showComposer && mailShortcutsApply,
|
||||
singleKeyShortcuts,
|
||||
emails: activeEmails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
@@ -1233,6 +1285,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
mountainViewKeys: mvShell,
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
const anyTabHandlers = useMemo(() => ({
|
||||
onShowHelp: keyboardHandlers.onShowHelp,
|
||||
...(calendarTabInFront ? {} : { onCompose: keyboardHandlers.onCompose }),
|
||||
}), [keyboardHandlers, calendarTabInFront]);
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer && !mailShortcutsApply,
|
||||
singleKeyShortcuts,
|
||||
emails: NO_EMAILS,
|
||||
handlers: anyTabHandlers,
|
||||
});
|
||||
// Ctrl+Z / z: undo the last mail actions, newest first.
|
||||
useUndoShortcut({ enabled: isAuthenticated && !showComposer, signedIn: isAuthenticated });
|
||||
|
||||
@@ -1470,6 +1532,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEmbedded, showComposer]);
|
||||
|
||||
// The window narrowed below Pro's width with a compose tab in front: go on
|
||||
// with its draft here. And the way back further down, in onSaveState.
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
const handedOff = takeHandedOffComposeDraft();
|
||||
if (!handedOff) return;
|
||||
const { draft, sourceEmailId, sourceEmailSource } = handedOff;
|
||||
setComposerSessionId((id) => id + 1);
|
||||
setHandedOffSource({ session: composerSessionId + 1, emailId: sourceEmailId, source: sourceEmailSource });
|
||||
setPendingDraft(draft);
|
||||
setComposerMode(draft.mode);
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
// Once, on arrival.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Check auth on mount – skip when already authenticated so that navigating
|
||||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||||
@@ -2167,6 +2246,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}, [isAuthenticated, client, mailboxes.length, emails, linkSegments, initialMailLoadDone, selectEmail]);
|
||||
|
||||
// The cursor only exists in Mountain View's desktop list. Narrower, the row it
|
||||
// is on would open in the reading pane as if it had been picked.
|
||||
useEffect(() => {
|
||||
if (mvShell || !cursorOnly) return;
|
||||
setCursorOnly(false);
|
||||
selectEmail(null);
|
||||
}, [mvShell, cursorOnly, selectEmail]);
|
||||
|
||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||
useEffect(() => {
|
||||
if (!selectedEmail || !client) return;
|
||||
@@ -2225,8 +2312,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
|
||||
// A Mountain View cursor is not a read receipt: the mail is only marked once it is
|
||||
// actually opened.
|
||||
if (mvShell && cursorOnly) {
|
||||
// actually opened. Leaving Mountain View's width drops the cursor below, so it
|
||||
// still counts in the render that notices.
|
||||
if (cursorOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2274,6 +2362,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Folder loaded, search results, the reconnecting and rate-limit banners.
|
||||
useMailStatusAnnouncements();
|
||||
|
||||
// A composer opened from the drawer (a plugin's birthday greeting, a contact)
|
||||
// must not stay under it.
|
||||
useEffect(() => {
|
||||
if (showComposer && (isMobile || isTablet)) setSidebarOpen(false);
|
||||
// Only when a composer opens: the drawer may be opened over one later.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showComposer]);
|
||||
|
||||
// Lock body scroll when sidebar is open on mobile/tablet
|
||||
useEffect(() => {
|
||||
if ((isMobile || isTablet) && sidebarOpen) {
|
||||
@@ -2323,6 +2419,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Its login and account, pinned too: the folder open at send time may hold
|
||||
// another account's message under the same id.
|
||||
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
|
||||
const handedOffToSession = handedOffSource?.session === composerSessionId ? handedOffSource : null;
|
||||
const answeredEmailId = handedOffToSession ? handedOffToSession.emailId : composerSourceEmail?.id;
|
||||
const answeredEmailSource = handedOffToSession ? handedOffToSession.source : composerSource;
|
||||
|
||||
const popoutsOn = usePopoutsAvailable();
|
||||
const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
|
||||
@@ -2406,7 +2505,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
try {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = composerSourceEmail?.id;
|
||||
const originalEmailId = answeredEmailId;
|
||||
|
||||
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
|
||||
submitted = true;
|
||||
@@ -2447,7 +2546,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
|
||||
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
|
||||
try {
|
||||
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, composerSource);
|
||||
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource);
|
||||
} catch (e) {
|
||||
debug.error(`Failed to set ${keyword} keyword:`, e);
|
||||
}
|
||||
@@ -3553,6 +3652,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
if (viewingClient) {
|
||||
// Keep an active search applied when switching folders (#553), unless
|
||||
@@ -3606,6 +3706,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
if (client) await fetchScheduledEmails(client);
|
||||
return;
|
||||
@@ -3625,6 +3726,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
const isSameView = captureViewToken();
|
||||
@@ -3663,6 +3765,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
const isSameView = captureViewToken();
|
||||
@@ -3702,6 +3805,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// On tablet, show the list again
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
@@ -3734,6 +3838,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// On tablet, show the list again
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
@@ -3762,6 +3867,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// On tablet, show the list again
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
if (isTogglingOff) {
|
||||
@@ -4706,12 +4812,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
|
||||
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
|
||||
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
|
||||
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : 'pane';
|
||||
composerHostRef.current = composerHost;
|
||||
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|
||||
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
|
||||
// A linked message on a phone opens over the list, as one picked there does.
|
||||
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
|
||||
const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
// A phone on its side counts as a tablet, which keeps the bottom bar. In a window
|
||||
// this short the bar takes a sixth of it and leaves a few lines, so it goes where
|
||||
// a phone held upright hides it too: over a composer, or a message filling the
|
||||
// window. With the list beside the message it is the only way to the other apps.
|
||||
const shortWindowHidesTabBar = isShortWindow && ((showComposer && !floatingComposer) || (hasViewerContent && shouldCollapseListPane));
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
@@ -5016,7 +5129,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
key={composerSessionId}
|
||||
key={`${composerSessionId}:${composerRemount}`}
|
||||
mode={pendingDraft?.mode ?? composerMode}
|
||||
composeFromAccountEmail={resolveComposeAccountEmail(
|
||||
mailboxes,
|
||||
@@ -5053,13 +5166,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
prefill={composerPrefill}
|
||||
initialFiles={composerInitialFiles}
|
||||
sessionKey={`inline-${composerSessionId}`}
|
||||
sourceEmailId={composerSourceEmail?.id}
|
||||
sourceEmailId={answeredEmailId}
|
||||
onSaveState={(data) => {
|
||||
setComposerPrefill(null);
|
||||
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
|
||||
suppressComposerStateSaveSessionRef.current = null;
|
||||
return;
|
||||
}
|
||||
// The window widened into Pro's width, which takes this page over:
|
||||
// the draft goes on in a compose tab.
|
||||
if (!isEmbedded && useSettingsStore.getState().proInterface && window.matchMedia(atLeast(1024)).matches) {
|
||||
useProTabStore.getState().openComposeTab({
|
||||
sessionId: composerSessionId + 1,
|
||||
mode: data.mode,
|
||||
replyTo: data.replyTo,
|
||||
initialData: data,
|
||||
sourceEmailId: answeredEmailId ?? null,
|
||||
sourceEmailSource: answeredEmailSource ?? null,
|
||||
title: data.subject?.trim() || t('email_composer.new_message'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Still open in another host: the one mounted there started before
|
||||
// these edits arrived. The composer calls the handler of its first
|
||||
// render, so composerHost is the host this instance lived in.
|
||||
if (showComposerRef.current && composerSessionRef.current === composerSessionId && composerHostRef.current !== composerHost) {
|
||||
setComposerRemount((n) => n + 1);
|
||||
}
|
||||
setPendingDraft(data);
|
||||
}}
|
||||
onSend={async (data) => {
|
||||
@@ -5291,7 +5424,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
<div className={cn("relative flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
|
||||
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
|
||||
<section
|
||||
ref={listPaneRef}
|
||||
aria-labelledby={listHeadingId}
|
||||
// Collapsed to no width under the reading pane: Tab must not walk
|
||||
// through its toolbar and rows, which nobody can see.
|
||||
inert={shouldCollapseListPane}
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background min-w-0",
|
||||
isHorizontalMailLayout
|
||||
@@ -5318,8 +5455,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
isMobile
|
||||
? undefined
|
||||
: isHorizontalMailLayout
|
||||
? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
|
||||
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: clampEmailListWidth(emailListWidth) } : undefined)
|
||||
? (!shouldHideHorizontalViewerPane ? { height: listPaneCssSize(emailListHeight, EMAIL_LIST_HEIGHT_MIN, showComposer ? COMPOSER_PANE_MIN_HEIGHT : READING_PANE_MIN_HEIGHT) } : undefined)
|
||||
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: listPaneCssSize(clampEmailListWidth(emailListWidth), EMAIL_LIST_MIN, READING_PANE_MIN_WIDTH) } : undefined)
|
||||
}
|
||||
>
|
||||
<h2 id={listHeadingId} className="sr-only">{t('shell.message_list')}</h2>
|
||||
@@ -5504,6 +5641,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onDone={() => {
|
||||
if (advancedSearchDebounceRef.current) void runAdvancedSearchNow();
|
||||
toggleAdvancedSearch();
|
||||
// On a phone the panel is full screen: the results are behind it.
|
||||
setMobileSearchOpen(false);
|
||||
}}
|
||||
folderSelect={!isUnifiedView ? renderSearchFolderSelect(SELECT_CLASS, true) : undefined}
|
||||
/>
|
||||
@@ -5533,7 +5672,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
|
||||
<WelcomeBanner />
|
||||
|
||||
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col">
|
||||
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
onFindFromSender={mvShell ? (sender) => void handleSelectContactSuggestion(sender, 'from') : undefined}
|
||||
@@ -5684,7 +5823,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-40 shadow-lg",
|
||||
// Under the drawer's backdrop (z-40), which comes earlier in the page.
|
||||
"absolute z-30 shadow-lg",
|
||||
mvMobile
|
||||
? "bottom-4 end-4 h-14 gap-3 rounded-2xl px-5"
|
||||
: isMobile
|
||||
@@ -5707,7 +5847,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
value={emailListWidth}
|
||||
min={EMAIL_LIST_MIN}
|
||||
max={EMAIL_LIST_MAX}
|
||||
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
|
||||
onResizeStart={() => { dragStartWidth.current = listPaneRef.current?.getBoundingClientRect().width ?? emailListWidth; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
|
||||
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
|
||||
onDoubleClick={resetEmailListWidth}
|
||||
@@ -5720,7 +5860,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
value={emailListHeight}
|
||||
min={EMAIL_LIST_HEIGHT_MIN}
|
||||
max={EMAIL_LIST_HEIGHT_MAX}
|
||||
onResizeStart={() => { dragStartWidth.current = emailListHeight; setIsResizing(true); }}
|
||||
onResizeStart={() => { dragStartWidth.current = listPaneRef.current?.getBoundingClientRect().height ?? emailListHeight; setIsResizing(true); }}
|
||||
onResize={(delta) => setEmailListHeight(dragStartWidth.current + delta)}
|
||||
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
|
||||
onDoubleClick={resetEmailListHeight}
|
||||
@@ -5862,6 +6002,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||
onUndoSpam={() => handleUndoSpam()}
|
||||
onMarkAsRead={async (emailId, read, opts) => {
|
||||
// The viewer stays mounted behind Mountain View's list and
|
||||
// counts the row under the cursor as opened. It isn't yet.
|
||||
if (opts?.automatic && cursorOnly) return;
|
||||
if (client) {
|
||||
const emailKey = selectedEmail?.id === emailId ? emailKeyFor(selectedEmail) : undefined;
|
||||
// Asked for (not on opening): Ctrl+Z can put it back.
|
||||
@@ -5976,7 +6119,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
|
||||
{(isMobile || isTablet) && mobileView !== "viewer" && !isEmbedded && (
|
||||
{(isMobile || isTablet) && mobileView !== "viewer" && !shortWindowHidesTabBar && !isEmbedded && (
|
||||
<NavigationRail
|
||||
orientation="horizontal"
|
||||
onManageApps={handleManageApps}
|
||||
|
||||
@@ -60,16 +60,18 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background text-foreground", className)} data-notification-panel="">
|
||||
<div className="flex items-center gap-1 px-3 h-12 border-b border-border shrink-0">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 px-3 py-1 min-h-12 border-b border-border shrink-0">
|
||||
{/* Full screen on a phone it is a dialog of its own, titled like one. */}
|
||||
<h2 className={cn("flex-1 min-w-0 truncate font-semibold", sheet ? "text-lg" : "text-sm")}>{t("title")}</h2>
|
||||
<Button variant="ghost" size="xs" onClick={markAllRead} disabled={unread === 0} className="gap-1 px-2 text-xs text-muted-foreground">
|
||||
<h2 className={cn("flex-[1_0_auto] max-w-full truncate font-semibold", sheet ? "text-lg" : "text-sm")}>{t("title")}</h2>
|
||||
{/* On a phone the title keeps the room and the actions are icons: their
|
||||
labels pushed the title out, and "Mark all as read" wrapped. */}
|
||||
<Button variant="ghost" size="xs" onClick={markAllRead} disabled={unread === 0} className="gap-1 px-2 text-xs text-muted-foreground shrink-0" title={t("mark_all_read")}>
|
||||
<CheckCheck className="w-4 h-4" />
|
||||
<span>{t("mark_all_read")}</span>
|
||||
<span className={cn(sheet && "max-sm:sr-only")}>{t("mark_all_read")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" onClick={clear} disabled={entries.length === 0} className="gap-1 px-2 text-xs text-muted-foreground">
|
||||
<Button variant="ghost" size="xs" onClick={clear} disabled={entries.length === 0} className="gap-1 px-2 text-xs text-muted-foreground shrink-0" title={t("clear")}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>{t("clear")}</span>
|
||||
<span className={cn(sheet && "max-sm:sr-only")}>{t("clear")}</span>
|
||||
</Button>
|
||||
{sheet && (
|
||||
<IconButton size="icon-lg" label={t("close")} title="" onClick={onClose} className="text-muted-foreground">
|
||||
|
||||
@@ -142,10 +142,11 @@ describe('actions for team mailboxes', () => {
|
||||
});
|
||||
|
||||
describe('PluginActions overflow button', () => {
|
||||
it('uses the vertical "more" icon, as every other overflow menu does (R2)', () => {
|
||||
it('uses the puzzle icon, not a second vertical "more" next to the toolbar\'s own', () => {
|
||||
act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
render(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1'] }} overflow />);
|
||||
const more = screen.getByRole('button', { name: 'more_actions' });
|
||||
expect(more.querySelector('.tabler-icon-dots-vertical')).not.toBeNull();
|
||||
expect(more.querySelector('.tabler-icon-puzzle')).not.toBeNull();
|
||||
expect(more.querySelector('.tabler-icon-dots-vertical')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
// Escape in a plugin dialog closes that dialog and nothing else. An event
|
||||
// popover or the event form behind it listens for Escape on the window.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitCustomDialog, awaitPrompt, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
const behind = vi.fn();
|
||||
const onWindowKey = (e: KeyboardEvent) => { if (e.key === 'Escape') behind(); };
|
||||
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
behind.mockClear();
|
||||
window.addEventListener('keydown', onWindowKey);
|
||||
});
|
||||
afterEach(() => window.removeEventListener('keydown', onWindowKey));
|
||||
|
||||
describe('Escape in a plugin dialog', () => {
|
||||
it('closes a custom dialog without reaching what is behind it', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<unknown>;
|
||||
act(() => { answer = awaitCustomDialog({ pluginId: 'p', title: 'Pick', message: '', slot: 'plugin-dialog' }); });
|
||||
|
||||
// What the bridge does for a frame-escape: the key event starts at the frame.
|
||||
await act(async () => { fireEvent.keyDown(screen.getByTestId('custom-frame'), { key: 'Escape' }); });
|
||||
|
||||
await expect(answer!).resolves.toBeNull();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes a prompt without reaching what is behind it', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<unknown>;
|
||||
act(() => { answer = awaitPrompt({ pluginId: 'p', kind: 'prompt', title: 'Name', message: '', fields: [{ name: 'n', label: 'Name' }] }); });
|
||||
|
||||
await act(async () => { fireEvent.keyDown(screen.getByRole('textbox', { name: 'Name' }), { key: 'Escape' }); });
|
||||
|
||||
await expect(answer!).resolves.toBeNull();
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves Escape to the rest of the app once the dialog is gone', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitCustomDialog({ pluginId: 'p', title: 'Pick', message: '', slot: 'plugin-dialog' }); });
|
||||
await act(async () => { fireEvent.keyDown(document.body, { key: 'Escape' }); });
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(behind).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,38 @@ describe('plugin dialog focus', () => {
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
});
|
||||
|
||||
it('leaves Enter to the focused button, so Cancel on a destructive confirm never answers yes', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<boolean>;
|
||||
act(() => { answer = awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Delete it?', message: 'Sure?', danger: true }); });
|
||||
const cancel = screen.getByRole('button', { name: 'cancel' });
|
||||
expect(cancel).toHaveFocus();
|
||||
|
||||
// Not prevented: the browser turns Enter on a button into a click on it.
|
||||
let notPrevented = false;
|
||||
await act(async () => { notPrevented = fireEvent.keyDown(cancel, { key: 'Enter' }); });
|
||||
expect(notPrevented).toBe(true);
|
||||
expect(head()).not.toBeNull();
|
||||
|
||||
await act(async () => { fireEvent.click(cancel); });
|
||||
await expect(answer!).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('does not take Enter from the message of a destructive confirm as a yes', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Delete it?', message: 'Sure?', danger: true }); });
|
||||
await act(async () => { fireEvent.keyDown(screen.getByText('Sure?'), { key: 'Enter' }); });
|
||||
expect(head()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts a harmless confirm on Enter from its message', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<boolean>;
|
||||
act(() => { answer = awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Send?', message: 'Sure?' }); });
|
||||
await act(async () => { fireEvent.keyDown(screen.getByText('Sure?'), { key: 'Enter' }); });
|
||||
await expect(answer!).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('starts in the first field of a prompt and marks required fields', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<Record<string, string> | null>;
|
||||
@@ -52,4 +84,29 @@ describe('plugin dialog focus', () => {
|
||||
await expect(answer!).resolves.toBeNull();
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
});
|
||||
|
||||
it('lets the keyboard reach a confirm message to scroll it, starting on the button', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Send?', message: 'A long list of recipients' }); });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'confirm' })).toHaveFocus();
|
||||
expect(screen.getByText('A long list of recipients').parentElement).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
it('lets the keyboard reach a prompt message to scroll it, starting in the field', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => {
|
||||
void awaitPrompt({ pluginId: 'p', kind: 'prompt', title: 'Name', message: 'A long explanation', fields: [{ name: 'n', label: 'Name' }] });
|
||||
});
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveFocus();
|
||||
expect(screen.getByText('A long explanation').parentElement).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
it('adds no stop for Tab when there is no message', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Send?', message: '' }); });
|
||||
|
||||
expect(screen.getByRole('dialog').querySelector('[tabindex="0"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
// Keys in a frame never reach the page, so a dialog with the focus in one of
|
||||
// its fields hears of Escape through a message from the frame. The real slot
|
||||
// and bridge here: only the frame's document is missing.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-api', () => ({ dispatchApiCall: vi.fn(async () => 'ok') }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({ dark: false }) }));
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: [], grantedPermissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
vi.mock('@/lib/plugin-sandbox/registry', () => ({
|
||||
get: () => ({
|
||||
plugin, code: '', tier: 'untrusted', background: {},
|
||||
slotOffers: [{ name: 'plugin-dialog', hasShouldShow: false, order: 100 }],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
const behind = vi.fn();
|
||||
const onWindowKey = (e: KeyboardEvent) => { if (e.key === 'Escape') behind(); };
|
||||
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
behind.mockClear();
|
||||
window.addEventListener('keydown', onWindowKey);
|
||||
});
|
||||
afterEach(() => window.removeEventListener('keydown', onWindowKey));
|
||||
|
||||
async function openDialog() {
|
||||
render(<PluginDialogHost />);
|
||||
let answer!: Promise<unknown>;
|
||||
act(() => { answer = awaitCustomDialog({ pluginId: 'p', title: 'Pick', message: '', slot: 'plugin-dialog' }); });
|
||||
const dialog = screen.getByRole('dialog', { name: 'Pick' });
|
||||
await waitFor(() => expect(dialog.querySelector('iframe')).not.toBeNull());
|
||||
return { answer, iframe: dialog.querySelector('iframe') as HTMLIFrameElement };
|
||||
}
|
||||
|
||||
const fromFrame = (iframe: HTMLIFrameElement, data: unknown, origin = 'null') => act(async () => {
|
||||
window.dispatchEvent(new MessageEvent('message', { data, origin, source: iframe.contentWindow }));
|
||||
});
|
||||
|
||||
describe('a frame-escape message from a plugin dialog', () => {
|
||||
it('closes the dialog and answers it as cancelled', async () => {
|
||||
const { answer, iframe } = await openDialog();
|
||||
await fromFrame(iframe, { type: 'frame-escape' });
|
||||
|
||||
await expect(answer).resolves.toBeNull();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts only when it comes from the dialog frame itself', async () => {
|
||||
const { iframe } = await openDialog();
|
||||
await fromFrame(iframe, { type: 'frame-escape' }, 'https://attacker.example');
|
||||
expect(screen.getByRole('dialog', { name: 'Pick' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
const evaluateShouldShow = vi.fn(async () => true);
|
||||
@@ -12,7 +12,11 @@ vi.mock('@/lib/plugin-sandbox/registry', () => ({
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: { evaluateShouldShow },
|
||||
slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }, { name: 'composer-sidebar-right', hasShouldShow: false, order: 100, fill: true }],
|
||||
slotOffers: [
|
||||
{ name: 'email-banner', hasShouldShow, order: 100 },
|
||||
{ name: 'composer-sidebar-right', hasShouldShow: false, order: 100, fill: true },
|
||||
{ name: 'toolbar-actions', hasShouldShow: false, order: 100 },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -52,6 +56,13 @@ describe('PluginIframeSlot', () => {
|
||||
expect(wrapper.style.flex).toBe('1 1 0%');
|
||||
});
|
||||
|
||||
it('keeps a filling frame at its minimum height when it is given one', async () => {
|
||||
activePlugin = plugin(['ui:composer-sidebar']);
|
||||
const { container } = render(<PluginIframeSlot pluginId="p1" slot="composer-sidebar-right" extraProps={{}} fill minFillHeight={280} />);
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect((container.querySelector('[data-plugin-iframe-slot]') as HTMLElement).style.minHeight).toBe('280px');
|
||||
});
|
||||
|
||||
it('mounts a banner-only plugin without the opened message', async () => {
|
||||
activePlugin = plugin(['ui:email-banner']);
|
||||
render(<PluginIframeSlot pluginId="p1" slot="email-banner" extraProps={{ email: EMAIL }} />);
|
||||
@@ -80,6 +91,43 @@ describe('PluginIframeSlot', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginIframeSlot with nothing to show yet', () => {
|
||||
type SlotOptions = { onResize: (height: number, width?: number) => void };
|
||||
const reportSize = (height: number, width?: number) => act(() => {
|
||||
((createSlotInstance.mock.calls[0] as unknown[])[0] as SlotOptions).onResize(height, width);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['block', 'email-banner', ['ui:email-banner']],
|
||||
['inline', 'toolbar-actions', ['ui:toolbar']],
|
||||
] as const)('keeps a %s frame rendered and out of the Tab order until it reports a height', async (_kind, slot, perms) => {
|
||||
activePlugin = plugin([...perms]);
|
||||
const { container } = render(<PluginIframeSlot pluginId="p1" slot={slot} extraProps={{}} />);
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const wrapper = container.querySelector('[data-plugin-iframe-slot]') as HTMLElement;
|
||||
|
||||
expect(wrapper).toHaveAttribute('inert');
|
||||
// Hidden, an isolated frame stops rendering and can never report the height that would unhide it.
|
||||
expect(wrapper.style.visibility).toBe('');
|
||||
|
||||
reportSize(32, 100);
|
||||
expect(wrapper).not.toHaveAttribute('inert');
|
||||
expect(wrapper.style.height).toBe('32px');
|
||||
|
||||
// Its content went away: nothing to land on again, still rendered.
|
||||
reportSize(0, 100);
|
||||
expect(wrapper).toHaveAttribute('inert');
|
||||
expect(wrapper.style.visibility).toBe('');
|
||||
});
|
||||
|
||||
it('leaves a frame that fills its panel alone: its height never comes from the frame', async () => {
|
||||
activePlugin = plugin(['ui:composer-sidebar']);
|
||||
const { container } = render(<PluginIframeSlot pluginId="p1" slot="composer-sidebar-right" extraProps={{}} fill />);
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
expect(container.querySelector('[data-plugin-iframe-slot]')).not.toHaveAttribute('inert');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginIframeSlot re-renders', () => {
|
||||
it('does not ask shouldShow again or push props when a re-render brings equal props', async () => {
|
||||
activePlugin = plugin(['ui:email-banner', 'email:read']);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user