mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 16:15:53 +00:00
Merge branch 'feat/desktop-offline' into feat/do-toast-place
This commit is contained in:
@@ -5,6 +5,7 @@ import { addPlainLanguage } from "@/i18n/plain-language";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||
import { PushBindingsHost } from "@/components/providers/push-bindings-host";
|
||||
import { FilesSyncPrompts } from "@/components/files-sync/files-sync-prompts";
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
@@ -34,6 +35,7 @@ import { NotificationActionsHost } from "@/components/providers/notification-act
|
||||
import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
|
||||
import { FileDropHost } from "@/components/file-drop/file-drop-host";
|
||||
import { EditedFilesHost } from "@/components/desktop/edited-files-host";
|
||||
import { FilesSyncHost } from "@/components/providers/files-sync-host";
|
||||
import { DevicePolicyHost } from "@/components/device/device-policy-host";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
@@ -97,8 +99,10 @@ export default async function LocaleLayout({
|
||||
<PushBindingsHost />
|
||||
<DesktopOsHost />
|
||||
{!IS_LITE && <EditedFilesHost />}
|
||||
{!IS_LITE && <FilesSyncHost />}
|
||||
<FreeBusyAutoShare />
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
{!IS_LITE && <FilesSyncPrompts />}
|
||||
</MainWindowOnly>
|
||||
{!IS_LITE && <WindowSyncHost />}
|
||||
{!IS_LITE && <WindowMenuBar />}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { freshLoginRequired } from "@/lib/app-lock/sign-out";
|
||||
import { ContextMenu, ContextMenuCheckItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo, useSyncExternalStore } from "react";
|
||||
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
|
||||
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
@@ -22,7 +24,6 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy, KeyRound } from "@/components/icons";
|
||||
@@ -369,7 +370,9 @@ function LoginPageContent() {
|
||||
const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [shakeError, setShakeError] = useState(false);
|
||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||
// The theme menu: the app's menu, as everywhere else.
|
||||
const themeMenu = useContextMenu<null>();
|
||||
const showThemeMenu = themeMenu.contextMenu.isOpen;
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -408,12 +411,6 @@ function LoginPageContent() {
|
||||
const prevError = useRef<string | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement>(null);
|
||||
const themeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const closeThemeMenu = useCallback(() => setShowThemeMenu(false), []);
|
||||
const { menuRef: themeListRef, onKeyDown: onThemeMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: showThemeMenu,
|
||||
onClose: closeThemeMenu,
|
||||
triggerRef: themeButtonRef,
|
||||
});
|
||||
// Captured by handleSubmit when in mobile handoff mode; consumed by the
|
||||
// isAuthenticated effect to build the deep-link fragment.
|
||||
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
|
||||
@@ -737,9 +734,6 @@ function LoginPageContent() {
|
||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) {
|
||||
setShowThemeMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
@@ -873,8 +867,8 @@ function LoginPageContent() {
|
||||
|
||||
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||
setTheme(newTheme);
|
||||
setShowThemeMenu(false);
|
||||
}, [setTheme]);
|
||||
themeMenu.closeContextMenu();
|
||||
}, [setTheme, themeMenu]);
|
||||
|
||||
// The server HTML, and every visit until /api/config answers: the card's
|
||||
// shape, the same one signed-out visitors of the app pages get first. Also
|
||||
@@ -1578,7 +1572,7 @@ function LoginPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||
onClick={(e) => (showThemeMenu ? themeMenu.closeContextMenu() : themeMenu.openContextMenuFrom(e.currentTarget, null, "end"))}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-9 h-9 rounded-xl border text-sm transition-all duration-200",
|
||||
showThemeMenu
|
||||
@@ -1594,39 +1588,24 @@ function LoginPageContent() {
|
||||
<CurrentThemeIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{showThemeMenu && (
|
||||
<div
|
||||
ref={themeListRef}
|
||||
onKeyDown={onThemeMenuKeyDown}
|
||||
className="absolute end-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||
role="menu"
|
||||
aria-label={tTheme("label")}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = theme === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => handleThemeSelect(option.value)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-start">{tTheme(option.label)}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu
|
||||
ref={themeMenu.menuRef}
|
||||
isOpen={showThemeMenu}
|
||||
position={themeMenu.contextMenu.position}
|
||||
onClose={themeMenu.closeContextMenu}
|
||||
label={tTheme("label")}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<ContextMenuCheckItem
|
||||
key={option.value}
|
||||
kind="radio"
|
||||
icon={option.icon}
|
||||
checked={theme === option.value}
|
||||
label={tTheme(option.label)}
|
||||
onClick={() => handleThemeSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[440px] mx-auto">
|
||||
@@ -1749,7 +1728,7 @@ function LoginPageContent() {
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||
onClick={(e) => (showThemeMenu ? themeMenu.closeContextMenu() : themeMenu.openContextMenuFrom(e.currentTarget, null, "end"))}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-9 h-9 rounded-xl border text-sm transition-all duration-200",
|
||||
showThemeMenu
|
||||
@@ -1765,39 +1744,24 @@ function LoginPageContent() {
|
||||
<CurrentThemeIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{showThemeMenu && (
|
||||
<div
|
||||
ref={themeListRef}
|
||||
onKeyDown={onThemeMenuKeyDown}
|
||||
className="absolute end-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||
role="menu"
|
||||
aria-label={tTheme("label")}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = theme === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => handleThemeSelect(option.value)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-start">{tTheme(option.label)}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu
|
||||
ref={themeMenu.menuRef}
|
||||
isOpen={showThemeMenu}
|
||||
position={themeMenu.contextMenu.position}
|
||||
onClose={themeMenu.closeContextMenu}
|
||||
label={tTheme("label")}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<ContextMenuCheckItem
|
||||
key={option.value}
|
||||
kind="radio"
|
||||
icon={option.icon}
|
||||
checked={theme === option.value}
|
||||
label={tTheme(option.label)}
|
||||
onClick={() => handleThemeSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[400px] mx-auto">
|
||||
|
||||
@@ -223,6 +223,7 @@
|
||||
[data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"],
|
||||
[data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"],
|
||||
[data-boot-settings-tab="files"] [data-sk-settings-tab="files"],
|
||||
[data-boot-settings-tab="files_sync"] [data-sk-settings-tab="files_sync"],
|
||||
[data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"],
|
||||
[data-boot-settings-tab="offline"] [data-sk-settings-tab="offline"],
|
||||
[data-boot-settings-tab="privacy"] [data-sk-settings-tab="privacy"],
|
||||
@@ -242,6 +243,7 @@
|
||||
@custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *));
|
||||
@custom-variant boot-no-settings-offline (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="offline"]) *));
|
||||
@custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *));
|
||||
@custom-variant boot-settings-files-sync (&:where([data-boot-settings-tabs~="files_sync"] *));
|
||||
|
||||
/* Settings pages with one section have no section jump row (SectionNav). */
|
||||
@custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *));
|
||||
|
||||
@@ -27,6 +27,8 @@ const OPTIONAL: Partial<Record<SettingsSearchTab, string>> = {
|
||||
calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden",
|
||||
contacts: "boot-no-settings-contacts:hidden",
|
||||
files: "boot-no-files:hidden boot-no-settings-files:hidden",
|
||||
// Desktop app only, while the files sync runs.
|
||||
files_sync: "hidden boot-settings-files-sync:flex",
|
||||
addons: "boot-no-settings-addons:hidden",
|
||||
// Not in the static Lite build.
|
||||
offline: "boot-no-settings-offline:hidden",
|
||||
|
||||
@@ -595,6 +595,21 @@ describe('CalendarApp task feedback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarApp quick editor', () => {
|
||||
// Usability report 2026-11-04: Save stayed off until a title was typed.
|
||||
// Google Calendar saves "(No title)"; so does the quick editor now.
|
||||
it('saves an event without a title as "(No title)"', async () => {
|
||||
renderApp();
|
||||
fireEvent.click(slots()[3]);
|
||||
const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' }, { timeout: 2000 });
|
||||
const save = within(quick).getByRole('button', { name: 'form.save' });
|
||||
expect(save).toBeEnabled();
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1));
|
||||
expect(store.createEvent.mock.calls[0][1]).toMatchObject({ title: 'events.no_title', showWithoutTime: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarApp creating in the month view', () => {
|
||||
it('opens the quick editor for an all-day event on a single click', async () => {
|
||||
setUp([], 'month');
|
||||
@@ -611,6 +626,70 @@ describe('CalendarApp creating in the month view', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Usability report 2026-11-04: a drag over the month's days did nothing.
|
||||
it('opens the quick editor for the days a drag picked and saves them as one all-day event', async () => {
|
||||
setUp([], 'month');
|
||||
renderApp();
|
||||
// Thursday 15 to Tuesday 20 October, across two week rows.
|
||||
const thursday = monthCell(/October 15, 2026/)!;
|
||||
const tuesday = monthCell(/October 20, 2026/)!;
|
||||
thursday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 0, right: 700 }) as DOMRect;
|
||||
tuesday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 100, bottom: 200, left: 0, right: 700 }) as DOMRect;
|
||||
thursday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 300, right: 400 }) as DOMRect;
|
||||
tuesday.getBoundingClientRect = () => ({ top: 100, bottom: 200, left: 100, right: 200 }) as DOMRect;
|
||||
const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 };
|
||||
fireEvent.pointerDown(thursday, { ...mouse, clientX: 350, clientY: 80 });
|
||||
fireEvent.pointerMove(thursday, { ...mouse, clientX: 150, clientY: 180 });
|
||||
fireEvent.pointerUp(thursday, { ...mouse, clientX: 150, clientY: 180 });
|
||||
|
||||
const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' });
|
||||
expect(within(quick).getByText('events.all_day')).toBeInTheDocument();
|
||||
expect(within(quick).getByText(/Oct 15 2026.* – .*Oct 20 2026/)).toBeInTheDocument();
|
||||
// No title needed: it saves as "(No title)".
|
||||
const save = within(quick).getByRole('button', { name: 'form.save' });
|
||||
expect(save).toBeEnabled();
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1));
|
||||
expect(store.createEvent.mock.calls[0][1]).toMatchObject({
|
||||
title: 'events.no_title', start: '2026-10-15T00:00:00', duration: 'P6D', showWithoutTime: true, calendarIds: { cal: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the full editor on the same days from "More options"', async () => {
|
||||
setUp([], 'month');
|
||||
renderApp();
|
||||
const thursday = monthCell(/October 15, 2026/)!;
|
||||
const friday = monthCell(/October 16, 2026/)!;
|
||||
thursday.closest('[data-week]')!.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 0, right: 700 }) as DOMRect;
|
||||
thursday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 300, right: 400 }) as DOMRect;
|
||||
friday.getBoundingClientRect = () => ({ top: 0, bottom: 100, left: 400, right: 500 }) as DOMRect;
|
||||
const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 };
|
||||
fireEvent.pointerDown(thursday, { ...mouse, clientX: 350, clientY: 80 });
|
||||
fireEvent.pointerMove(thursday, { ...mouse, clientX: 450, clientY: 80 });
|
||||
fireEvent.pointerUp(thursday, { ...mouse, clientX: 450, clientY: 80 });
|
||||
const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' });
|
||||
fireEvent.click(within(quick).getByRole('button', { name: 'quick_create.more_options' }));
|
||||
const dates = await waitFor(() => {
|
||||
const found = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="date"]')).map((input) => input.value);
|
||||
expect(found.length).toBeGreaterThan(1);
|
||||
return found;
|
||||
});
|
||||
expect(dates.slice(0, 2)).toEqual(['2026-10-15', '2026-10-16']);
|
||||
});
|
||||
|
||||
it('offers no new event in the month view without a calendar that can take one', () => {
|
||||
setUp([], 'month');
|
||||
useCalendarStore.setState({
|
||||
calendars: [{ ...calendars[0], myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false } }],
|
||||
} as never);
|
||||
renderApp();
|
||||
const day = monthCell(/October 15, 2026/)!;
|
||||
fireEvent.click(day);
|
||||
fireEvent.keyDown(day.querySelector('[data-day-button]')!, { key: 'ArrowRight', shiftKey: true });
|
||||
expect(screen.queryByRole('dialog', { name: 'quick_create.aria_label' })).toBeNull();
|
||||
expect(day.closest('[role="grid"]')!.getAttribute('aria-multiselectable')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the day on a phone tap instead', () => {
|
||||
media.mobile = true;
|
||||
media.desktop = false;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { render, fireEvent, cleanup, act, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach, beforeAll } from 'vitest';
|
||||
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// Usability report 2026-11-04: in the month view a drag over the days did
|
||||
// nothing. It now picks days, as in Google Calendar and Outlook, and opens
|
||||
// the quick editor for an all-day event over them.
|
||||
|
||||
const announce = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/announce', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/announce')>()),
|
||||
announce,
|
||||
}));
|
||||
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
const standup = {
|
||||
id: 'e1', '@type': 'Event', uid: 'e1', title: 'Standup', start: '2026-11-05T00:00:00', duration: 'P1D',
|
||||
showWithoutTime: true, calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
type Props = React.ComponentProps<typeof CalendarMonthView>;
|
||||
|
||||
function renderMonth(overrides: Partial<Props> = {}) {
|
||||
const props: Props = {
|
||||
focus: { date: new Date(2026, 10, 4), nonce: 0 },
|
||||
windowKey: 'month:2026-11-04',
|
||||
// Monday 2 Nov to Sunday 22 Nov 2026: three week rows.
|
||||
rangeStart: new Date(2026, 10, 2),
|
||||
rangeEnd: new Date(2026, 10, 22),
|
||||
selectedDate: new Date(2026, 10, 4),
|
||||
events: [standup],
|
||||
calendars,
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
onCreateAllDay: vi.fn(),
|
||||
onCreateRange: vi.fn(),
|
||||
onOpenDay: vi.fn(),
|
||||
onContextMenuEmpty: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
const view = render(<CalendarMonthView {...props} />);
|
||||
layOut(props.isMobile ? false : document.documentElement.dir === 'rtl');
|
||||
return { props, ...view };
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom lays nothing out: give the week rows and day cells the boxes a
|
||||
* 700 px wide grid has (100 px per day and row), mirrored in a
|
||||
* right-to-left grid.
|
||||
*/
|
||||
function layOut(rtl: boolean) {
|
||||
const box = (left: number, top: number, width: number, height: number) =>
|
||||
() => ({ left, top, right: left + width, bottom: top + height, width, height, x: left, y: top, toJSON() {} }) as DOMRect;
|
||||
document.querySelectorAll<HTMLElement>('[data-week]').forEach((row, r) => {
|
||||
row.getBoundingClientRect = box(0, r * 100, 700, 100);
|
||||
row.querySelectorAll<HTMLElement>('[data-month-day]').forEach((cell, c) => {
|
||||
cell.getBoundingClientRect = box((rtl ? 6 - c : c) * 100, r * 100, 100, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cell = (key: string) => document.querySelector<HTMLElement>(`[data-month-day="${key}"]`)!;
|
||||
/** The middle of a day's box, in the layout above. */
|
||||
function pointOf(key: string) {
|
||||
const rect = cell(key).getBoundingClientRect();
|
||||
return { clientX: rect.left + 50, clientY: rect.top + 80 };
|
||||
}
|
||||
const picked = () => Array.from(document.querySelectorAll('[data-month-day][aria-selected="true"]')).map((el) => el.getAttribute('data-month-day'));
|
||||
|
||||
const mouse = { pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0 };
|
||||
function drag(from: string, to: string, via: string[] = [], release = true) {
|
||||
const grid = cell(from);
|
||||
fireEvent.pointerDown(grid, { ...mouse, ...pointOf(from) });
|
||||
for (const key of [...via, to]) fireEvent.pointerMove(grid, { ...mouse, ...pointOf(key) });
|
||||
if (release) fireEvent.pointerUp(grid, { ...mouse, ...pointOf(to) });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
});
|
||||
beforeEach(() => announce.mockClear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.documentElement.dir = '';
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Month view: dragging over days', () => {
|
||||
it('picks the days in reading order across week rows and creates over them', () => {
|
||||
const { props } = renderMonth();
|
||||
drag('2026-11-05', '2026-11-10', ['2026-11-06', '2026-11-09'], false);
|
||||
// Thursday to next Tuesday, wrapping at the end of the row; the
|
||||
// selected day (Wednesday 4th) keeps its own mark.
|
||||
expect(picked()).toEqual(['2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09', '2026-11-10']);
|
||||
expect(cell('2026-11-07').className).toContain('bg-primary/15');
|
||||
fireEvent.pointerUp(cell('2026-11-05'), { ...mouse, ...pointOf('2026-11-10') });
|
||||
|
||||
expect(props.onCreateRange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 10));
|
||||
expect(props.onCreateAllDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works backwards too', () => {
|
||||
const { props } = renderMonth();
|
||||
drag('2026-11-10', '2026-11-05', ['2026-11-09', '2026-11-06']);
|
||||
expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 10));
|
||||
});
|
||||
|
||||
it('mirrors in a right-to-left grid: the day to the left is the next one', () => {
|
||||
document.documentElement.dir = 'rtl';
|
||||
const { props } = renderMonth();
|
||||
// Thursday 5th sits at x 200-300; Saturday 7th to its left at 0-100.
|
||||
expect(pointOf('2026-11-07').clientX).toBeLessThan(pointOf('2026-11-05').clientX);
|
||||
drag('2026-11-05', '2026-11-07');
|
||||
expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 7));
|
||||
});
|
||||
|
||||
it('leaves a click on the empty space of a day to the click: one all-day event there', () => {
|
||||
const { props } = renderMonth();
|
||||
const day = cell('2026-11-12');
|
||||
fireEvent.pointerDown(day, { ...mouse, ...pointOf('2026-11-12') });
|
||||
fireEvent.pointerUp(day, { ...mouse, ...pointOf('2026-11-12') });
|
||||
fireEvent.click(day);
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
expect(props.onCreateAllDay).toHaveBeenCalledWith(new Date(2026, 10, 12));
|
||||
});
|
||||
|
||||
it("doesn't count the click that ends a drag as a click on the last day", () => {
|
||||
const { props } = renderMonth();
|
||||
drag('2026-11-12', '2026-11-13');
|
||||
fireEvent.click(cell('2026-11-13'));
|
||||
expect(props.onCreateRange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCreateAllDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts nothing from an event bar, the date number or "+N more"', () => {
|
||||
// Six events on the 12th: some go under "+N more".
|
||||
const busy = Array.from({ length: 6 }, (_, i) => ({ ...standup, id: `b${i}`, uid: `b${i}`, title: `Busy ${i}`, start: '2026-11-12T00:00:00' }));
|
||||
const { props } = renderMonth({ events: [standup, ...busy] });
|
||||
const bar = screen.getByRole('button', { name: /Standup/ });
|
||||
const dateNumber = cell('2026-11-12').querySelector<HTMLElement>('[data-day-button]')!;
|
||||
const more = screen.getByText('events.more');
|
||||
for (const target of [bar, dateNumber, more]) {
|
||||
fireEvent.pointerDown(target, { ...mouse, ...pointOf('2026-11-05') });
|
||||
fireEvent.pointerMove(target, { ...mouse, ...pointOf('2026-11-06') });
|
||||
fireEvent.pointerUp(target, { ...mouse, ...pointOf('2026-11-06') });
|
||||
}
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
expect(picked()).toEqual(['2026-11-04']);
|
||||
});
|
||||
|
||||
it('picks nothing where no calendar can take a new event', () => {
|
||||
const { props } = renderMonth({ onCreateRange: undefined, onCreateAllDay: undefined });
|
||||
drag('2026-11-05', '2026-11-10', ['2026-11-06'], false);
|
||||
expect(picked()).toEqual(['2026-11-04']);
|
||||
fireEvent.pointerUp(cell('2026-11-05'), { ...mouse, ...pointOf('2026-11-10') });
|
||||
expect(props.onOpenDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the days out while they are picked', async () => {
|
||||
renderMonth();
|
||||
drag('2026-11-05', '2026-11-10', [], false);
|
||||
expect(announce).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/^selected_range$/),
|
||||
expect.objectContaining({ key: 'calendar-month-selection' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the days of the quick editor and spans its placeholder over them', () => {
|
||||
renderMonth({
|
||||
pendingPreview: { start: new Date(2026, 10, 5), end: new Date(2026, 10, 10), title: 'Trip', allDay: true, calendarId: 'cal-1' },
|
||||
});
|
||||
expect(picked()).toEqual(['2026-11-04', '2026-11-05', '2026-11-06', '2026-11-07', '2026-11-08', '2026-11-09', '2026-11-10']);
|
||||
const bars = Array.from(document.querySelectorAll<HTMLElement>('[data-pending-preview]'));
|
||||
// Thursday to Sunday in the first row, Monday and Tuesday in the next.
|
||||
expect(bars.map((bar) => bar.closest('[data-month-day]')!.getAttribute('data-month-day'))).toEqual(['2026-11-05', '2026-11-09']);
|
||||
expect(bars.map((bar) => (bar.parentElement as HTMLElement).style.width)).toEqual(['calc(400% + 2px)', 'calc(200% + 0px)']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Month view: picking days by keyboard', () => {
|
||||
const key = (el: HTMLElement, k: string, shiftKey = false) => fireEvent.keyDown(el, { key: k, shiftKey });
|
||||
const dateButton = (day: string) => cell(day).querySelector<HTMLElement>('[data-day-button]')!;
|
||||
|
||||
it('grows the selection with Shift+arrows and creates over it with Enter', () => {
|
||||
const { props } = renderMonth();
|
||||
key(dateButton('2026-11-04'), 'ArrowRight', true);
|
||||
key(dateButton('2026-11-05'), 'ArrowDown', true);
|
||||
expect(picked()).toEqual(expect.arrayContaining(['2026-11-04', '2026-11-05', '2026-11-08', '2026-11-12']));
|
||||
expect(announce).toHaveBeenLastCalledWith('selected_range enter_hint', expect.anything());
|
||||
key(dateButton('2026-11-12'), 'Enter');
|
||||
expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 4), new Date(2026, 10, 12));
|
||||
expect(props.onOpenDay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Enter opening the day without a selection, and Escape drops one', () => {
|
||||
const { props } = renderMonth();
|
||||
key(dateButton('2026-11-04'), 'ArrowRight', true);
|
||||
key(dateButton('2026-11-05'), 'Escape');
|
||||
expect(picked()).toEqual(['2026-11-04']);
|
||||
const enter = fireEvent.keyDown(dateButton('2026-11-05'), { key: 'Enter' });
|
||||
expect(enter).toBe(true);
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the selection on a plain arrow', () => {
|
||||
const { props } = renderMonth();
|
||||
key(dateButton('2026-11-04'), 'ArrowRight', true);
|
||||
key(dateButton('2026-11-05'), 'ArrowRight');
|
||||
key(dateButton('2026-11-06'), 'Enter');
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Month view: picking days on a touch screen', () => {
|
||||
const finger = { pointerId: 7, pointerType: 'touch', isPrimary: true, button: 0 };
|
||||
|
||||
it('starts after a long press, then follows the finger', () => {
|
||||
vi.useFakeTimers();
|
||||
const { props } = renderMonth();
|
||||
const start = cell('2026-11-05');
|
||||
fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') });
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
expect(picked()).toContain('2026-11-05');
|
||||
fireEvent.pointerMove(start, { ...finger, ...pointOf('2026-11-12') });
|
||||
// The grid doesn't scroll under a finger picking days.
|
||||
const move = new Event('touchmove', { cancelable: true, bubbles: true });
|
||||
start.dispatchEvent(move);
|
||||
expect(move.defaultPrevented).toBe(true);
|
||||
fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-12') });
|
||||
expect(props.onCreateRange).toHaveBeenCalledWith(new Date(2026, 10, 5), new Date(2026, 10, 12));
|
||||
// The long press's own menu doesn't open over the new event.
|
||||
fireEvent.contextMenu(start);
|
||||
expect(props.onContextMenuEmpty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a swipe scroll: nothing is picked', () => {
|
||||
vi.useFakeTimers();
|
||||
const { props } = renderMonth();
|
||||
const start = cell('2026-11-05');
|
||||
fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') });
|
||||
fireEvent.pointerMove(start, { ...finger, clientX: 150, clientY: 140 });
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
const move = new Event('touchmove', { cancelable: true, bubbles: true });
|
||||
start.dispatchEvent(move);
|
||||
expect(move.defaultPrevented).toBe(false);
|
||||
fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-12') });
|
||||
expect(picked()).toEqual(['2026-11-04']);
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the long-press menu of a single day', () => {
|
||||
vi.useFakeTimers();
|
||||
const { props } = renderMonth();
|
||||
const start = cell('2026-11-05');
|
||||
fireEvent.pointerDown(start, { ...finger, ...pointOf('2026-11-05') });
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
fireEvent.contextMenu(start);
|
||||
expect(props.onContextMenuEmpty).not.toHaveBeenCalled();
|
||||
fireEvent.pointerUp(start, { ...finger, ...pointOf('2026-11-05') });
|
||||
expect(props.onCreateRange).not.toHaveBeenCalled();
|
||||
expect(props.onContextMenuEmpty).toHaveBeenCalledWith(expect.anything(), new Date(2026, 10, 5), undefined, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
|
||||
import { CalendarWeekView } from '../calendar-week-view';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// Usability report 2026-11-04: dragging over an empty stretch of a day drew
|
||||
// a see-through selection the events showed through, and the new event's
|
||||
// placeholder lay over the events it overlapped ("Lernblock" hidden behind
|
||||
// it) instead of taking a column beside them.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function timed(id: string, start: string, duration: string): CalendarEvent {
|
||||
return {
|
||||
id, '@type': 'Event', uid: id, title: id, start, duration, timeZone: null,
|
||||
showWithoutTime: false, calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
const lernblock = timed('Lernblock', '2026-09-09T12:30:00', 'PT1H');
|
||||
const draft = {
|
||||
start: new Date(2026, 8, 9, 11, 45), end: new Date(2026, 8, 9, 15, 45),
|
||||
title: '(No title)', allDay: false, calendarId: 'cal-1',
|
||||
};
|
||||
|
||||
type WeekProps = React.ComponentProps<typeof CalendarWeekView>;
|
||||
|
||||
function renderWeek(overrides: Partial<WeekProps> = {}) {
|
||||
const props: WeekProps = {
|
||||
focus: { date: new Date(2026, 8, 9), nonce: 0 },
|
||||
windowKey: 'week:2026-09-07',
|
||||
rangeStart: new Date(2026, 8, 7),
|
||||
rangeEnd: new Date(2026, 8, 13),
|
||||
selectedDate: new Date(2026, 8, 9),
|
||||
events: [],
|
||||
calendars,
|
||||
onSelectDate: vi.fn(),
|
||||
onSelectEvent: vi.fn(),
|
||||
onCreateAtTime: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { props, ...render(<CalendarWeekView {...props} />) };
|
||||
}
|
||||
|
||||
/** The time column of a day: the grid cell holding its hour slots. */
|
||||
function column(key: string): HTMLElement {
|
||||
const slot = document.querySelector<HTMLElement>(`[data-slot^="${key}T"]`);
|
||||
const cell = slot?.closest<HTMLElement>('[role="gridcell"]');
|
||||
if (!cell) throw new Error(`no column for ${key}`);
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** The pixel offset of a time in a column whose hours are 60 px tall. */
|
||||
function yOf(key: string, minutes: number): number {
|
||||
const first = Number(document.querySelector(`[data-slot^="${key}T"]`)!.getAttribute('data-slot')!.split('T')[1]);
|
||||
return minutes - first * 60;
|
||||
}
|
||||
|
||||
/** The positioned box around the drawn placeholder of the new event. */
|
||||
function placeholderBox(): HTMLElement {
|
||||
const preview = document.querySelector<HTMLElement>('[data-pending-preview]');
|
||||
if (!preview) throw new Error('no placeholder');
|
||||
return preview.style.width ? preview : preview.parentElement!;
|
||||
}
|
||||
|
||||
function eventBox(id: string): HTMLElement {
|
||||
const box = Array.from(document.querySelectorAll<HTMLElement>('[data-calendar-event]'))
|
||||
.find((el) => el.textContent?.includes(id));
|
||||
if (!box) throw new Error(`no event ${id}`);
|
||||
return box;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom has no pointer capture.
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('Week view drag-to-create', () => {
|
||||
it('draws the selection opaque and above the events it passes over', () => {
|
||||
renderWeek({ events: [lernblock] });
|
||||
const col = column('2026-09-09');
|
||||
fireEvent.pointerDown(col, { pointerId: 1, pointerType: 'mouse', button: 0, clientY: yOf('2026-09-09', 675) });
|
||||
fireEvent.pointerMove(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 975) });
|
||||
|
||||
const selection = col.querySelector<HTMLElement>('[data-drag-selection]');
|
||||
expect(selection).not.toBeNull();
|
||||
expect(selection!.textContent).toContain('11:15 – 16:15');
|
||||
// An opaque fill: the event under it doesn't show through.
|
||||
expect(selection!.style.backgroundColor).toContain('var(--color-background)');
|
||||
expect(selection!.className).toContain('z-30');
|
||||
expect(eventBox('Lernblock').className).toContain('z-10');
|
||||
});
|
||||
|
||||
it('creates the range the selection showed', () => {
|
||||
const { props } = renderWeek({ events: [lernblock] });
|
||||
const col = column('2026-09-09');
|
||||
fireEvent.pointerDown(col, { pointerId: 1, pointerType: 'mouse', button: 0, clientY: yOf('2026-09-09', 675) });
|
||||
fireEvent.pointerMove(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 975) });
|
||||
fireEvent.pointerUp(col, { pointerId: 1, pointerType: 'mouse', clientY: yOf('2026-09-09', 940) });
|
||||
|
||||
expect(props.onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 9, 11, 15), new Date(2026, 8, 9, 16, 15));
|
||||
});
|
||||
});
|
||||
|
||||
describe('The new event beside the events it overlaps', () => {
|
||||
it('shares the width with them in the week view', () => {
|
||||
renderWeek({ events: [lernblock], pendingPreview: draft });
|
||||
expect(placeholderBox().style.width).toBe('50%');
|
||||
expect(eventBox('Lernblock').style.width).toBe('50%');
|
||||
// Each in its own half.
|
||||
expect(placeholderBox().style.insetInlineStart).not.toBe(eventBox('Lernblock').style.insetInlineStart);
|
||||
});
|
||||
|
||||
it('keeps the whole width where nothing overlaps it', () => {
|
||||
renderWeek({ events: [timed('Later', '2026-09-09T17:00:00', 'PT1H')], pendingPreview: draft });
|
||||
expect(placeholderBox().style.width).toBe('100%');
|
||||
});
|
||||
|
||||
it('shares the width with them in the day view', () => {
|
||||
render(
|
||||
<CalendarDayView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="day:2026-09-09"
|
||||
rangeStart={new Date(2026, 8, 9)}
|
||||
rangeEnd={new Date(2026, 8, 9)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[lernblock]}
|
||||
calendars={calendars}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
pendingPreview={draft}
|
||||
/>,
|
||||
);
|
||||
expect(placeholderBox().style.width).toBe('50%');
|
||||
expect(eventBox('Lernblock').style.width).toBe('50%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { EmptySpaceContextMenu } from '../empty-space-context-menu';
|
||||
|
||||
// The calendar grid's right-click menu shows the keys that do the same (N, T), as the
|
||||
// mail menus do; with single keys off they do nothing, so they aren't shown.
|
||||
|
||||
const props = {
|
||||
position: { x: 10, y: 10 },
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
menuRef: { current: null },
|
||||
onNewEvent: vi.fn(),
|
||||
onNewAllDayEvent: vi.fn(),
|
||||
onGoToToday: vi.fn(),
|
||||
};
|
||||
|
||||
const keyOf = (testId: string) => screen.getByTestId(testId).querySelector('[data-menu-shortcut]')?.textContent ?? null;
|
||||
|
||||
afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true } as never));
|
||||
|
||||
describe('the calendar grid menu', () => {
|
||||
it('shows N for a new event and T for today', () => {
|
||||
useSettingsStore.setState({ singleKeyShortcuts: true } as never);
|
||||
render(<EmptySpaceContextMenu {...props} />);
|
||||
expect(keyOf('grid-new-event')).toBe('N');
|
||||
expect(keyOf('grid-go-to-today')).toBe('T');
|
||||
});
|
||||
|
||||
it('shows none with single keys off', () => {
|
||||
useSettingsStore.setState({ singleKeyShortcuts: false } as never);
|
||||
render(<EmptySpaceContextMenu {...props} />);
|
||||
expect(keyOf('grid-new-event')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw, Search } from "@/components/icons";
|
||||
import {
|
||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||
format, parseISO, startOfWeek,
|
||||
differenceInCalendarDays, format, parseISO, startOfWeek,
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { isCalendarViewMode, type CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -1269,6 +1269,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
handleGridCreate(start, start, true);
|
||||
}, [handleGridCreate]);
|
||||
|
||||
// Days picked in the month view: an all-day event from the first to the
|
||||
// last, in the same quick editor. Not offered without a calendar that can
|
||||
// take it (read-only and subscribed ones only).
|
||||
const canCreateEvents = writableCalendars.length > 0;
|
||||
const handleGridCreateDays = useCallback((first: Date, last: Date) => {
|
||||
const start = new Date(first);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(last);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
handleGridCreate(start, end, true);
|
||||
}, [handleGridCreate]);
|
||||
|
||||
const closeQuickCreate = useCallback(() => setQuickDraft(null), []);
|
||||
|
||||
// The create button and `n` make what the view shows: a task in Tasks.
|
||||
@@ -1289,14 +1301,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
const handleQuickCreateSave = useCallback(async () => {
|
||||
if (!quickDraft) return;
|
||||
const { start, end, title, calendarId, allDay } = quickDraft;
|
||||
const { start, end, calendarId, allDay } = quickDraft;
|
||||
// Saving without a title is allowed, as in Google Calendar: the event
|
||||
// is called "(No title)" in the user's language.
|
||||
const title = quickDraft.title.trim() || t("events.no_title");
|
||||
if (allDay) {
|
||||
setQuickDraft(null);
|
||||
// Every day from the first to the last one picked (a drag in the month view).
|
||||
const days = Math.max(1, differenceInCalendarDays(end, start) + 1);
|
||||
await handleSaveEvent({
|
||||
uid: generateUUID(),
|
||||
title: title.trim(),
|
||||
title,
|
||||
start: format(start, "yyyy-MM-dd'T'00:00:00"),
|
||||
duration: "P1D",
|
||||
duration: `P${days}D`,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
calendarIds: { [calendarId]: true },
|
||||
@@ -1315,7 +1332,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
// as the full editor's fields, so they are labelled with that zone.
|
||||
await handleSaveEvent({
|
||||
uid: generateUUID(),
|
||||
title: title.trim(),
|
||||
title,
|
||||
start: format(start, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration,
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
@@ -1325,7 +1342,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
freeBusyStatus: "busy",
|
||||
privacy: "public",
|
||||
});
|
||||
}, [quickDraft, handleSaveEvent]);
|
||||
}, [quickDraft, handleSaveEvent, t]);
|
||||
|
||||
// While the quick editor is open its placeholder takes the preview slot.
|
||||
const gridPreview = useMemo<PendingEventPreview | null>(() => {
|
||||
@@ -1984,7 +2001,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onReplyEvent={handleReplyEvent}
|
||||
onCreateAllDay={handleGridCreateAllDay}
|
||||
onCreateAllDay={canCreateEvents ? handleGridCreateAllDay : undefined}
|
||||
onCreateRange={canCreateEvents ? handleGridCreateDays : undefined}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={gridPreview}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EventCard, eventFillClass, eventFillStyle } from "./event-card";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, pendingTimedMinutes, PENDING_LAYOUT_ID } from "@/lib/calendar-utils";
|
||||
import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-display-range";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
@@ -203,6 +203,25 @@ export function CalendarDayView({
|
||||
return map;
|
||||
}, [days, eventsByDay, hours]);
|
||||
|
||||
// The new event being created takes a column beside the events it
|
||||
// overlaps, as it will once saved, instead of covering them. Only its
|
||||
// times count: typing its title doesn't redo the layout.
|
||||
const pendingStartMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.start.getTime() : null;
|
||||
const pendingEndMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.end.getTime() : null;
|
||||
const pendingLayout = useMemo(() => {
|
||||
if (pendingStartMs === null || pendingEndMs === null) return null;
|
||||
const preview = { start: new Date(pendingStartMs), end: new Date(pendingEndMs) };
|
||||
const day = days.find((d) => isSameDay(d, preview.start));
|
||||
const bounds = day ? pendingTimedMinutes(preview, day) : null;
|
||||
if (!day || !bounds) return null;
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const partition = partitionByDisplayHours(eventsByDay.get(key)?.timed ?? [], day, hours);
|
||||
const all = layoutOverlappingEvents(partition.visible, day, bounds);
|
||||
const pending = all.find((layout) => layout.event.id === PENDING_LAYOUT_ID);
|
||||
if (!pending) return null;
|
||||
return { key, layouts: all.filter((layout) => layout.event.id !== PENDING_LAYOUT_ID), pending };
|
||||
}, [pendingStartMs, pendingEndMs, days, eventsByDay, hours]);
|
||||
|
||||
const scrollToFocus = useCallback(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
@@ -279,7 +298,7 @@ export function CalendarDayView({
|
||||
}, []);
|
||||
|
||||
const {
|
||||
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
|
||||
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, handleGridPointerCancel,
|
||||
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
|
||||
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
|
||||
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
|
||||
@@ -519,7 +538,7 @@ export function CalendarDayView({
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const today = isDisplayToday(day);
|
||||
const dayLayout = layoutByDay.get(key);
|
||||
const layouted = dayLayout?.layouts ?? [];
|
||||
const layouted = (pendingLayout?.key === key ? pendingLayout.layouts : dayLayout?.layouts) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -529,6 +548,7 @@ export function CalendarDayView({
|
||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||
onPointerMove={handleGridPointerMove}
|
||||
onPointerUp={handleGridPointerUp}
|
||||
onPointerCancel={handleGridPointerCancel}
|
||||
onDragOver={(e) => handleColumnDragOver(e, key)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, day)}
|
||||
@@ -656,8 +676,11 @@ export function CalendarDayView({
|
||||
|
||||
{dragCreate?.dayKey === key && (
|
||||
<div
|
||||
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
|
||||
data-drag-selection
|
||||
// Opaque and above the events (z-10), which showed through a see-through fill.
|
||||
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none border-2 border-primary/60 border-dashed"
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--color-primary) 15%, var(--color-background))",
|
||||
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * hourHeight,
|
||||
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * hourHeight,
|
||||
}}
|
||||
@@ -683,32 +706,37 @@ export function CalendarDayView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||
{pendingPreview && pendingLayout?.key === key && (
|
||||
(() => {
|
||||
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||
const durationMin = Math.max(15, endMin - startMin);
|
||||
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
|
||||
const { startMinutes: startMin, endMinutes: endMin, column, totalColumns } = pendingLayout.pending;
|
||||
if (endMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, endMin, hours);
|
||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||
const color = paint(cal?.color || "#3b82f6");
|
||||
// Drawn like a saved event, so the new event shows where
|
||||
// it will land while it is being created.
|
||||
// Drawn like a saved event, in the column it will take, so the new
|
||||
// event shows where it will land while it is being created.
|
||||
return (
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn("absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md", eventFillClass(tintedEvents, false))}
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
|
||||
height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight),
|
||||
...eventFillStyle(color, { inactive: false, tinted: tintedEvents }),
|
||||
insetInlineStart: `${(column / totalColumns) * 100}%`,
|
||||
width: `${(1 / totalColumns) * 100}%`,
|
||||
paddingInline: 2,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium truncate">
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[0.65625rem] opacity-85 truncate">
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn("h-full rounded-xs overflow-hidden px-1.5 py-0.5 text-xs shadow-md", eventFillClass(tintedEvents, false))}
|
||||
style={eventFillStyle(color, { inactive: false, tinted: tintedEvents })}
|
||||
>
|
||||
<div className="font-medium truncate">
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[0.65625rem] opacity-85 truncate">
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(endMin, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, useRef, useLayoutEffect, type DragEvent } from "react";
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, useLayoutEffect, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format, parseISO, eachDayOfInterval, addDays, isSameDay } from "date-fns";
|
||||
import { format, parseISO, eachDayOfInterval, addDays, isSameDay, differenceInCalendarDays, startOfDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
@@ -26,6 +26,8 @@ import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
|
||||
import { isInMonthRange, useMonthRangeSelect, type MonthRange } from "@/hooks/use-month-range-select";
|
||||
import { announce } from "@/lib/announce";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -41,6 +43,12 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void;
|
||||
/** A click on a day's empty space: a new all-day event there (wide screens). */
|
||||
onCreateAllDay?: (date: Date) => void;
|
||||
/**
|
||||
* Days picked by a drag over empty day space, a long press and drag on
|
||||
* touch, or Shift+arrows and Enter: a new all-day event over them. Left
|
||||
* out where no calendar can take a new event.
|
||||
*/
|
||||
onCreateRange?: (start: Date, end: Date) => void;
|
||||
/** Open a day in the day view (its date number, or its "N more" link). */
|
||||
onOpenDay?: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
@@ -162,6 +170,7 @@ export function CalendarMonthView({
|
||||
onContextMenuEmpty,
|
||||
onReplyEvent,
|
||||
onCreateAllDay,
|
||||
onCreateRange,
|
||||
onOpenDay,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
@@ -174,6 +183,7 @@ export function CalendarMonthView({
|
||||
onMoveEvent,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSelect = useTranslations("calendar_month_select");
|
||||
const paint = useCvdColor();
|
||||
const describeError = useCalendarErrorMessage();
|
||||
// An event of another signed-in login is read with that login's addresses.
|
||||
@@ -271,6 +281,36 @@ export function CalendarMonthView({
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Picking days to create an event over them (drag, long press, Shift+arrows).
|
||||
const rangeSelect = useMonthRangeSelect({
|
||||
scrollRef,
|
||||
enabled: !!onCreateRange,
|
||||
onCreateRange: (start, end) => onCreateRange?.(start, end),
|
||||
onContextMenuDay: onContextMenuEmpty ? (e, day) => onContextMenuEmpty(e, day, undefined, true) : undefined,
|
||||
});
|
||||
// The days stay marked while the quick editor for them is open; Escape
|
||||
// or a click elsewhere closes it and the mark goes with it.
|
||||
const draftRange = useMemo<MonthRange | null>(() => {
|
||||
if (!pendingPreview?.allDay) return null;
|
||||
const start = startOfDay(pendingPreview.start);
|
||||
const end = startOfDay(pendingPreview.end);
|
||||
return end < start ? { start, end: start } : { start, end };
|
||||
}, [pendingPreview?.allDay, pendingPreview?.start, pendingPreview?.end]);
|
||||
const highlight = rangeSelect.selection ?? draftRange;
|
||||
const picking = rangeSelect.selection;
|
||||
const announcement = picking
|
||||
? [
|
||||
isSameDay(picking.start, picking.end)
|
||||
? tSelect("selected_day", { day: formatFullDate(picking.start) })
|
||||
: tSelect("selected_range", { start: formatFullDate(picking.start), end: formatFullDate(picking.end) }),
|
||||
picking.keyboard ? tSelect("enter_hint") : "",
|
||||
].join(" ").trim()
|
||||
: "";
|
||||
// The days picked, read out as they change (the last of a quick run).
|
||||
useEffect(() => {
|
||||
if (announcement) announce(announcement, { key: "calendar-month-selection", debounceMs: 250 });
|
||||
}, [announcement]);
|
||||
|
||||
// Six rows fill the viewport, as a single month used to; taller rows grow
|
||||
// with their chips.
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
@@ -420,8 +460,21 @@ export function CalendarMonthView({
|
||||
const focusKey = dayKey(focus.date);
|
||||
return keys.has(focusKey) ? focusKey : (weeks[0]?.[0] ? dayKey(weeks[0][0]) : selectedKey);
|
||||
}, [weeks, selectedKey, focus.date]);
|
||||
const { extendTo, commitKeyboard, clearKeyboard } = rangeSelect;
|
||||
const handleDayKeyDown = useCallback((e: React.KeyboardEvent<HTMLButtonElement>, day: Date, dayIndex: number) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
// Enter over days picked with Shift+arrows creates there; without
|
||||
// them it opens the day, as before.
|
||||
if (e.key === "Enter" && !e.shiftKey && commitKeyboard()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && clearKeyboard()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && !onCreateRange) return;
|
||||
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
||||
let delta: number;
|
||||
switch (e.key) {
|
||||
@@ -438,9 +491,11 @@ export function CalendarMonthView({
|
||||
const next = addDays(day, delta);
|
||||
const target = scrollRef.current?.querySelector<HTMLButtonElement>(`[data-day-button="${dayKey(next)}"]`);
|
||||
if (!target) return;
|
||||
if (e.shiftKey) extendTo(day, next);
|
||||
else clearKeyboard();
|
||||
onSelectDate(next);
|
||||
target.focus();
|
||||
}, [onSelectDate]);
|
||||
}, [onSelectDate, onCreateRange, extendTo, commitKeyboard, clearKeyboard]);
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
@@ -484,13 +539,20 @@ export function CalendarMonthView({
|
||||
}, [t, describeError, userEmailsFor, onMoveEvent]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={`${t("views.month")}, ${formatMonthYear(visibleMonthDate)}`}>
|
||||
<div
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
role="grid"
|
||||
aria-label={`${t("views.month")}, ${formatMonthYear(visibleMonthDate)}`}
|
||||
aria-multiselectable={onCreateRange ? true : undefined}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
// At 150 % the pane's edge falls inside a device pixel, and the week
|
||||
// scrolled under the header showed a line of its events there.
|
||||
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none] [clip-path:inset(0.5px_0_0_0)]"
|
||||
// No text selection or touch callout: a drag over the days picks days.
|
||||
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none] [clip-path:inset(0.5px_0_0_0)] select-none [-webkit-touch-callout:none]"
|
||||
onScroll={handleScroll}
|
||||
{...rangeSelect.pointerHandlers}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
@@ -512,7 +574,7 @@ export function CalendarMonthView({
|
||||
const fit = capRows ? fitMonthWeek(segments, dayEventRows, dayTasks.map((list) => list.length), rowCapacity, moreInHeader) : null;
|
||||
const hiddenPerDay = fit?.hidden ?? 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;
|
||||
const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day) || isInMonthRange(day, draftRange)) ? 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 && !capRows
|
||||
@@ -530,13 +592,17 @@ export function CalendarMonthView({
|
||||
const fullDateLabel = formatFullDate(day);
|
||||
const previous = dayIndex > 0 ? week[dayIndex - 1] : addDays(day, -1);
|
||||
const firstOfMonth = !checkIsSameMonth(day, previous);
|
||||
const picked = isInMonthRange(day, highlight);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="gridcell"
|
||||
aria-selected={selected}
|
||||
data-month-day={key}
|
||||
aria-selected={selected || picked}
|
||||
onClick={() => {
|
||||
// The end of a drag over days, not a click on this one.
|
||||
if (rangeSelect.wasDragging.current) return;
|
||||
onSelectDate(day);
|
||||
// On phones a tap opens the day instead.
|
||||
if (!isMobile) onCreateAllDay?.(day);
|
||||
@@ -550,7 +616,8 @@ export function CalendarMonthView({
|
||||
!inMonth && "bg-muted/30",
|
||||
"hover:bg-muted/50",
|
||||
selected && isMobile && "bg-primary/10",
|
||||
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
|
||||
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10",
|
||||
picked && "bg-primary/15 hover:bg-primary/15"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 mb-0.5">
|
||||
@@ -649,6 +716,7 @@ export function CalendarMonthView({
|
||||
isMobile={isMobile}
|
||||
calendarMap={calendarMap}
|
||||
pendingPreview={pendingPreview}
|
||||
draftRange={draftRange}
|
||||
lastRow={capRows ? rowCapacity - 1 : Infinity}
|
||||
selectedEventId={selectedEventId}
|
||||
currentUserEmails={currentUserEmails}
|
||||
@@ -693,7 +761,7 @@ type WeekSegment = ReturnType<typeof buildWeekSegments>[number];
|
||||
*/
|
||||
function MonthCellEntries({
|
||||
dayIndex, week, segments, rowLimit, isShown, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight,
|
||||
isMobile, calendarMap, pendingPreview, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle,
|
||||
isMobile, calendarMap, pendingPreview, draftRange, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle,
|
||||
onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onReplyEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, moreInHeader = false, paint,
|
||||
}: {
|
||||
dayIndex: number;
|
||||
@@ -713,6 +781,8 @@ function MonthCellEntries({
|
||||
isMobile?: boolean;
|
||||
calendarMap: Map<string, Calendar>;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
/** The days of a new all-day event being created: its bar spans them. */
|
||||
draftRange?: MonthRange | null;
|
||||
/** The last row a fixed-height week has room for; the preview must not go past it. */
|
||||
lastRow: number;
|
||||
selectedEventId?: string | null;
|
||||
@@ -746,13 +816,25 @@ function MonthCellEntries({
|
||||
// A click, double click or right-click on an entry is the entry's, not a
|
||||
// click on the empty day (which creates an all-day event there).
|
||||
const own = {
|
||||
// Not empty day space: a drag from here doesn't pick days.
|
||||
"data-month-entry": true,
|
||||
onClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
onContextMenu: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
};
|
||||
const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && (isShown ? isShown(segment) : segment.row < rowLimit));
|
||||
const tasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex < rowLimit);
|
||||
const preview = pendingPreview && isSameDay(pendingPreview.start, day) ? pendingPreview : null;
|
||||
// A new all-day event spans its days, from its first day or the start
|
||||
// of each week row it continues into; a timed one sits on its day.
|
||||
const previewSpan = (() => {
|
||||
if (!pendingPreview) return 0;
|
||||
if (draftRange) {
|
||||
if (!isInMonthRange(day, draftRange) || (dayIndex > 0 && !isSameDay(day, draftRange.start))) return 0;
|
||||
return Math.min(differenceInCalendarDays(draftRange.end, day) + 1, week.length - dayIndex);
|
||||
}
|
||||
return isSameDay(pendingPreview.start, day) ? 1 : 0;
|
||||
})();
|
||||
const preview = previewSpan > 0 ? pendingPreview : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -835,10 +917,12 @@ function MonthCellEntries({
|
||||
)}
|
||||
{preview && (() => {
|
||||
// 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));
|
||||
let busiest = 0;
|
||||
for (let d = dayIndex; d < dayIndex + previewSpan; d++) busiest = Math.max(busiest, dayEventRows[d] + dayTasks[d].length);
|
||||
const previewRow = Math.min(lastRow, Math.max(rowCount, busiest));
|
||||
const color = paint(calendarMap.get(preview.calendarId)?.color || "#3b82f6");
|
||||
return (
|
||||
<div className="absolute z-20 px-0.5 pointer-events-none" style={place(previewRow)}>
|
||||
<div className="absolute z-20 px-0.5 pointer-events-none" style={place(previewRow, previewSpan)}>
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn(
|
||||
|
||||
@@ -507,6 +507,8 @@ export function CalendarSidebarPanel({
|
||||
<ContextMenuItem
|
||||
icon={Eraser}
|
||||
label={tUx('clear_all_events')}
|
||||
destructive
|
||||
testId="calendar-clear-all"
|
||||
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ToolbarButton } from "@/components/ui/toolbar";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuCheckItem, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { startOfWeek } from "date-fns";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
@@ -473,21 +473,14 @@ export function CalendarToolbar({
|
||||
</Button>
|
||||
<ContextMenu ref={viewMenuRef} isOpen={viewMenu.isOpen} position={viewMenu.position} onClose={closeViewMenu}>
|
||||
{menuViews.map((v) => (
|
||||
<button
|
||||
<ContextMenuCheckItem
|
||||
key={v}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={v === viewMode}
|
||||
kind="radio"
|
||||
checked={v === viewMode}
|
||||
label={t(`views.${v}`)}
|
||||
shortcut={VIEW_SHORTCUTS[v]}
|
||||
onClick={() => { closeViewMenu(); onViewModeChange(v); }}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-6 w-full px-3 py-2 rounded-md text-sm transition-colors outline-none focus-visible:bg-accent",
|
||||
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<span data-menu-label="">{t(`views.${v}`)}</span>
|
||||
{/* The key, on devices with a keyboard (R8). */}
|
||||
<MenuShortcut keys={VIEW_SHORTCUTS[v]} />
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { QuickEventInput } from "./quick-event-input";
|
||||
import { CalendarTaskChip } from "./task-chip";
|
||||
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
|
||||
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
|
||||
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils";
|
||||
import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments, pendingTimedMinutes, PENDING_LAYOUT_ID } from "@/lib/calendar-utils";
|
||||
import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remapSegmentsToShownDays, resolveWorkingDays } from "@/lib/calendar-display-range";
|
||||
import { displayNow, isDisplayToday } from "@/lib/timezone";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
@@ -238,6 +238,25 @@ export function CalendarWeekView({
|
||||
return map;
|
||||
}, [days, timedEvents, hours]);
|
||||
|
||||
// The new event being created takes a column beside the events it
|
||||
// overlaps, as it will once saved, instead of covering them. Only its
|
||||
// times count: typing its title doesn't redo the layout.
|
||||
const pendingStartMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.start.getTime() : null;
|
||||
const pendingEndMs = pendingPreview && !pendingPreview.allDay ? pendingPreview.end.getTime() : null;
|
||||
const pendingLayout = useMemo(() => {
|
||||
if (pendingStartMs === null || pendingEndMs === null) return null;
|
||||
const preview = { start: new Date(pendingStartMs), end: new Date(pendingEndMs) };
|
||||
const day = days.find((d) => isSameDay(d, preview.start));
|
||||
const bounds = day ? pendingTimedMinutes(preview, day) : null;
|
||||
if (!day || !bounds) return null;
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const partition = partitionByDisplayHours(timedEvents.get(key) || [], day, hours);
|
||||
const all = layoutOverlappingEvents(partition.visible, day, bounds);
|
||||
const pending = all.find((layout) => layout.event.id === PENDING_LAYOUT_ID);
|
||||
if (!pending) return null;
|
||||
return { key, layouts: all.filter((layout) => layout.event.id !== PENDING_LAYOUT_ID), pending };
|
||||
}, [pendingStartMs, pendingEndMs, days, timedEvents, hours]);
|
||||
|
||||
// Built over the consecutive days and then moved onto the drawn columns,
|
||||
// so an event across a hidden weekend still spans Friday to Monday.
|
||||
const allDaySegments = useMemo(() => {
|
||||
@@ -378,7 +397,7 @@ export function CalendarWeekView({
|
||||
}, []);
|
||||
|
||||
const {
|
||||
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
|
||||
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp, handleGridPointerCancel,
|
||||
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
|
||||
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
|
||||
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
|
||||
@@ -664,7 +683,7 @@ export function CalendarWeekView({
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const todayCol = isDisplayToday(day);
|
||||
const dayLayout = layoutByDay.get(key);
|
||||
const layouted = dayLayout?.layouts ?? [];
|
||||
const layouted = (pendingLayout?.key === key ? pendingLayout.layouts : dayLayout?.layouts) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -674,6 +693,7 @@ export function CalendarWeekView({
|
||||
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||
onPointerMove={handleGridPointerMove}
|
||||
onPointerUp={handleGridPointerUp}
|
||||
onPointerCancel={handleGridPointerCancel}
|
||||
onDragOver={(e) => handleColumnDragOver(e, key)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, day)}
|
||||
@@ -801,8 +821,11 @@ export function CalendarWeekView({
|
||||
|
||||
{dragCreate?.dayKey === key && (
|
||||
<div
|
||||
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
|
||||
data-drag-selection
|
||||
// Opaque and above the events (z-10), which showed through a see-through fill.
|
||||
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none border-2 border-primary/60 border-dashed"
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--color-primary) 15%, var(--color-background))",
|
||||
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * hourHeight,
|
||||
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * hourHeight,
|
||||
}}
|
||||
@@ -828,33 +851,37 @@ export function CalendarWeekView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
|
||||
{pendingPreview && pendingLayout?.key === key && (
|
||||
(() => {
|
||||
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
|
||||
let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
|
||||
if (endMin <= startMin) endMin = 1440;
|
||||
const durationMin = Math.max(15, endMin - startMin);
|
||||
if (startMin + durationMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, startMin + durationMin, hours);
|
||||
const { startMinutes: startMin, endMinutes: endMin, column, totalColumns } = pendingLayout.pending;
|
||||
if (endMin <= hours.startMinutes || startMin >= hours.endMinutes) return null;
|
||||
const clip = clipToDisplayHours(startMin, endMin, hours);
|
||||
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
|
||||
const color = paint(cal?.color || "#3b82f6");
|
||||
// Drawn like a saved event, so the new event shows where
|
||||
// it will land while it is being created.
|
||||
// Drawn like a saved event, in the column it will take, so the new
|
||||
// event shows where it will land while it is being created.
|
||||
return (
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn("absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md", eventFillClass(tintedEvents, false))}
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
|
||||
height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight),
|
||||
...eventFillStyle(color, { inactive: false, tinted: tintedEvents }),
|
||||
insetInlineStart: `${(column / totalColumns) * 100}%`,
|
||||
width: `${(1 / totalColumns) * 100}%`,
|
||||
paddingInline: 2,
|
||||
}}
|
||||
>
|
||||
<div className="font-medium truncate">
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[0.65625rem] opacity-85 truncate">
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
|
||||
<div
|
||||
data-pending-preview
|
||||
className={cn("h-full rounded-xs overflow-hidden px-1.5 py-0.5 text-xs shadow-md", eventFillClass(tintedEvents, false))}
|
||||
style={eventFillStyle(color, { inactive: false, tinted: tintedEvents })}
|
||||
>
|
||||
<div className="font-medium truncate">
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[0.65625rem] opacity-85 truncate">
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(endMin, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Plus, CalendarDays, CheckSquare, Clock } from "@/components/icons";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -37,6 +38,8 @@ export function EmptySpaceContextMenu({
|
||||
showAllDayOption = true,
|
||||
}: EmptySpaceContextMenuProps) {
|
||||
const t = useTranslations("calendar");
|
||||
// The calendar's own keys (N, T), as the shortcut help lists them; gone with single keys off.
|
||||
const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false);
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
fn();
|
||||
@@ -45,7 +48,7 @@ export function EmptySpaceContextMenu({
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||
<ContextMenuItem icon={Plus} label={t("events.new_event")} shortcut={singleKeys ? "N" : undefined} onClick={handle(onNewEvent)} testId="grid-new-event" />
|
||||
{showAllDayOption && (
|
||||
<ContextMenuItem
|
||||
icon={CalendarDays}
|
||||
@@ -61,7 +64,7 @@ export function EmptySpaceContextMenu({
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} shortcut={singleKeys ? "T" : undefined} onClick={handle(onGoToToday)} testId="grid-go-to-today" />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format } from "date-fns";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Clock, X } from "@/components/icons";
|
||||
@@ -58,8 +58,9 @@ export function computePosition(anchor: DOMRect | null, height: number, width =
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact editor that opens next to an empty slot clicked in the week or
|
||||
* day view: a title, the time that was picked, the calendar, and a way into
|
||||
* Compact editor that opens next to an empty slot clicked or dragged over in
|
||||
* the week or day view, or days picked in the month view: a title, the time
|
||||
* that was picked, the calendar, and a way into
|
||||
* the full editor. Enter saves, Escape or a click elsewhere discards.
|
||||
*/
|
||||
export function QuickCreatePopover({
|
||||
@@ -148,14 +149,19 @@ export function QuickCreatePopover({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const canSave = draft.title.trim().length > 0 && !!draft.calendarId;
|
||||
// An empty title saves as "(No title)", as in Google Calendar.
|
||||
const canSave = !!draft.calendarId;
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canSave) onSave();
|
||||
}, [canSave, onSave]);
|
||||
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
const dateLabel = intlFormatter.dateTime(draft.start, { weekday: "long", month: "long", day: "numeric" });
|
||||
const dayFormat = { weekday: "long", month: "long", day: "numeric" } as const;
|
||||
// Days picked by a drag in the month view: the first and the last.
|
||||
const dateLabel = draft.allDay && !isSameDay(draft.start, draft.end)
|
||||
? `${intlFormatter.dateTime(draft.start, dayFormat)} – ${intlFormatter.dateTime(draft.end, dayFormat)}`
|
||||
: intlFormatter.dateTime(draft.start, dayFormat);
|
||||
const timeLabel = draft.allDay ? t("events.all_day") : `${format(draft.start, timeFmt)} – ${format(draft.end, timeFmt)}`;
|
||||
|
||||
return createPortal(
|
||||
|
||||
@@ -745,6 +745,7 @@ export function ContactsSidebar({
|
||||
{(["to", "cc", "bcc"] as const).map((field) => (
|
||||
<ContextMenuItem
|
||||
key={field}
|
||||
icon={Mail}
|
||||
label={t(`groups.send_email_${field}`)}
|
||||
onClick={() => {
|
||||
const groupId = groupContextMenu.data!.id;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { DEFAULT_POLICY_SNAPSHOT, type PolicySnapshot } from '@/lib/desktop/policy-schema';
|
||||
import type { BulwarkDesktopBridge, DesktopRecentFile } from '@/lib/desktop/types';
|
||||
|
||||
// Recent files in the Attach menu, in the desktop app: the shell's side is
|
||||
// repos/desktop's test/recent-files.spec.ts; here the bridge is a stub.
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
picked: [] as unknown[],
|
||||
beforeAttachmentUpload: vi.fn(async (_info: { name: string }) => true),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/files/file-picker-dialog', () => ({
|
||||
FilePickerDialog: ({ onPick }: { onPick: (nodes: unknown[]) => void }) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{ type: 'button', 'data-testid': 'fake-file-picker', onClick: () => onPick(hoisted.picked) },
|
||||
'pick',
|
||||
),
|
||||
}));
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-plugin-attachment-source.test.tsx) ─
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
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/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
emptySubjectWarningEnabled: true,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
updateSetting: () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: hoisted.toastError, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
identityHooks: {
|
||||
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onIdentitySelect: { size: 0, emit: async () => {} },
|
||||
},
|
||||
templateHooks: {
|
||||
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
|
||||
onTemplateApply: { size: 0, emit: async () => {} },
|
||||
},
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
onDraftChange: { emit: () => {} },
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
|
||||
onComposeSend: { takeOver: async () => 'default' },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
plainTextBodyHasSignature: () => false,
|
||||
plainTextBodyWithoutSignature: (body: string) => body,
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = Date.now();
|
||||
const RECENT: DesktopRecentFile[] = [
|
||||
{ id: 'id-report', name: 'Quarterly report.docx', folder: '\\\\files\\team\\Reports', path: '\\\\files\\team\\Reports\\Quarterly report.docx', usedAt: NOW - 5 * 60_000, icon: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
{ id: 'id-budget', name: 'Budget 2027.xlsx', folder: 'C:\\Users\\me\\Documents', path: 'C:\\Users\\me\\Documents\\Budget 2027.xlsx', usedAt: NOW - 3 * 60 * 60_000 },
|
||||
];
|
||||
|
||||
interface DesktopOptions {
|
||||
files?: DesktopRecentFile[];
|
||||
policyOff?: boolean;
|
||||
/** The shell's native menus: the Attach menu must stay HTML all the same. */
|
||||
popup?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function desktop({ files = RECENT, policyOff = false, popup }: DesktopOptions = {}) {
|
||||
const snapshot: PolicySnapshot = {
|
||||
...DEFAULT_POLICY_SNAPSHOT,
|
||||
...(policyOff ? { RecentFilesInAttach: { value: false, source: 'forced-machine', locked: true } } : {}),
|
||||
} as PolicySnapshot;
|
||||
const list = vi.fn(async () => ({ ok: true as const, files }));
|
||||
const attach = vi.fn(async (id: string) => {
|
||||
const file = files.find((f) => f.id === id);
|
||||
return file
|
||||
? { ok: true as const, file: { name: file.name, data: new TextEncoder().encode(`bytes of ${file.name}`) } }
|
||||
: { ok: false as const, reason: 'unknown' as const };
|
||||
});
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
policy: { get: () => snapshot, onChange: () => () => {} },
|
||||
recentFiles: { list, attach },
|
||||
...(popup ? { menu: { popup, setState: () => {}, onCommand: () => () => {} } } : {}),
|
||||
} as unknown as BulwarkDesktopBridge;
|
||||
return { list, attach };
|
||||
}
|
||||
|
||||
function client(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
createDraft: vi.fn().mockResolvedValue('draft-1'),
|
||||
getEmail: vi.fn(),
|
||||
hasDelayedSend: () => false,
|
||||
getMaxDelayedSend: () => 0,
|
||||
// No Files, no plugins: in a browser, Attach is a plain button.
|
||||
supportsFiles: () => false,
|
||||
getMaxSizeAttachmentsPerEmail: () => 0,
|
||||
getMaxSizeUpload: () => 0,
|
||||
uploadBlob: vi.fn(() => new Promise(() => {})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const rows = () => screen.queryAllByTestId('composer-recent-file');
|
||||
|
||||
async function openMenu() {
|
||||
fireEvent.click(screen.getByTestId('composer-attach-menu'));
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
describe('recent files in the Attach menu', () => {
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
useAuthStore.setState({ client: null });
|
||||
vi.clearAllMocks();
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
it('in a browser the paperclip is the plain button it was', () => {
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('in the desktop app the paperclip opens a menu: recent files first, then the file dialog', async () => {
|
||||
const { list } = desktop();
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
await openMenu();
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
|
||||
const menu = screen.getByRole('menu');
|
||||
const items = within(menu).getAllByRole('menuitem');
|
||||
expect(items.map((item) => item.getAttribute('data-testid'))).toEqual(['composer-recent-file', 'composer-recent-file', 'composer-attach-from-device']);
|
||||
expect(within(menu).getByText('heading')).toBeInTheDocument();
|
||||
expect(items[2]).toHaveTextContent('browse');
|
||||
|
||||
const [report, budget] = rows();
|
||||
expect(report).toHaveTextContent('Quarterly report.docx');
|
||||
expect(report).toHaveTextContent('\\\\files\\team\\Reports');
|
||||
expect(report).toHaveTextContent('5 minutes ago');
|
||||
expect(report).toHaveAttribute('title', '\\\\files\\team\\Reports\\Quarterly report.docx');
|
||||
expect(report.querySelector('img')).toHaveAttribute('src', RECENT[0].icon);
|
||||
// No icon from the shell: the app's own for the type.
|
||||
expect(budget.querySelector('img')).toBeNull();
|
||||
expect(budget.querySelector('svg')).not.toBeNull();
|
||||
expect(budget).toHaveTextContent('3 hours ago');
|
||||
});
|
||||
|
||||
it('one click attaches that file through the composer\'s own checks and closes the menu', async () => {
|
||||
const { attach } = desktop();
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
fireEvent.click(rows()[1]);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(attach).toHaveBeenCalledWith('id-budget');
|
||||
expect(await screen.findByText('Budget 2027.xlsx')).toBeInTheDocument();
|
||||
expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledWith(expect.objectContaining({ name: 'Budget 2027.xlsx', size: 'bytes of Budget 2027.xlsx'.length }));
|
||||
});
|
||||
|
||||
it('a plugin can refuse it, as it can a file from the dialog', async () => {
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'Budget 2027.xlsx');
|
||||
desktop();
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
fireEvent.click(rows()[1]);
|
||||
await act(async () => {});
|
||||
expect(hoisted.beforeAttachmentUpload).toHaveBeenCalled();
|
||||
expect(screen.queryByText('Budget 2027.xlsx')).toBeNull();
|
||||
});
|
||||
|
||||
it('the server\'s size limit applies too', async () => {
|
||||
desktop();
|
||||
useAuthStore.setState({ client: client({ getMaxSizeUpload: () => 5 }) as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
fireEvent.click(rows()[0]);
|
||||
await act(async () => {});
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large');
|
||||
expect(screen.queryByText('Quarterly report.docx')).toBeNull();
|
||||
});
|
||||
|
||||
it('says so when the file is gone or too large for the app', async () => {
|
||||
const { attach } = desktop();
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
attach.mockResolvedValueOnce({ ok: false, reason: 'missing' } as never);
|
||||
await openMenu();
|
||||
fireEvent.click(rows()[0]);
|
||||
await act(async () => {});
|
||||
expect(hoisted.toastError).toHaveBeenLastCalledWith('missing');
|
||||
|
||||
attach.mockResolvedValueOnce({ ok: false, reason: 'too-large' } as never);
|
||||
await openMenu();
|
||||
fireEvent.click(rows()[0]);
|
||||
await act(async () => {});
|
||||
expect(hoisted.toastError).toHaveBeenLastCalledWith('too_large');
|
||||
});
|
||||
|
||||
it('with nothing recent the menu has only its other entries', async () => {
|
||||
desktop({ files: [] });
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(within(menu).queryByText('heading')).toBeNull();
|
||||
expect(within(menu).queryByRole('separator')).toBeNull();
|
||||
expect(within(menu).getAllByRole('menuitem').map((item) => item.getAttribute('data-testid'))).toEqual(['composer-attach-from-device']);
|
||||
});
|
||||
|
||||
it('with the policy off there\'s no list, and Attach is what it was', async () => {
|
||||
const { list } = desktop({ policyOff: true });
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'attach' }));
|
||||
await act(async () => {});
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keys: the first file has the focus, arrows move, Escape closes', async () => {
|
||||
desktop();
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
await waitFor(() => expect(document.activeElement).toBe(rows()[0]), { timeout: 5000 });
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(rows()[1]);
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toHaveAttribute('data-testid', 'composer-attach-from-device');
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
it('stays an HTML menu where the shell would draw it natively', async () => {
|
||||
const popup = vi.fn(async () => null);
|
||||
desktop({ popup });
|
||||
useAuthStore.setState({ client: client() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} />);
|
||||
await openMenu();
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); });
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
expect(rows()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -334,6 +334,42 @@ describe('composer close guard with a follow-up', () => {
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a shell that has windows of its own', () => {
|
||||
const windowBridge = (answer: unknown) => {
|
||||
const confirm = vi.fn(async () => 2);
|
||||
const show = vi.fn(async () => answer);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm, show, cancel: vi.fn() } } as never;
|
||||
return { confirm, show };
|
||||
};
|
||||
|
||||
it('asks in the app\'s own window, not the OS box, with Try again as the default', async () => {
|
||||
const { confirm, show } = windowBridge(null);
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(show).toHaveBeenCalledTimes(1));
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(show.mock.calls[0]).toMatchObject([{
|
||||
title: 'close_draft_title',
|
||||
message: [{ text: 'save_failed_close' }],
|
||||
buttons: [{ label: 'cancel', variant: 'secondary' }, { label: 'discard', variant: 'destructive' }, { label: 'try_again' }],
|
||||
defaultIndex: 2,
|
||||
}]);
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
// Escape: nothing happens.
|
||||
await act(async () => {});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Discard in the window discards, and the follow-up runs', async () => {
|
||||
windowBridge({ button: 1 });
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the follow-up after discarding a draft that could not be saved', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { PHISHING_KEYWORD } from '@/lib/phishing-report';
|
||||
@@ -50,14 +50,11 @@ function makeEmail(attachment: Record<string, unknown>, keywords: Record<string,
|
||||
/** Answers anything the views ask of a client with nothing. */
|
||||
const fakeClient = new Proxy({}, { get: (_target, name) => (name === 'then' ? undefined : () => undefined) });
|
||||
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(async () => null);
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: { setState: () => {}, popup },
|
||||
menu: { setState: () => {} },
|
||||
// The shell's list, for the ones used here.
|
||||
edit: { canEdit: (name: string) => /\.(docx|txt)$/.test(name), list: async () => [], onChange: () => () => {} },
|
||||
} as never;
|
||||
@@ -74,8 +71,8 @@ async function viewerMenu(email: Email): Promise<(string | undefined)[]> {
|
||||
render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onArchive={vi.fn()} onDelete={vi.fn()} />);
|
||||
const tile = await screen.findByTestId('attachment');
|
||||
fireEvent.contextMenu(tile.parentElement ?? tile, { clientX: 5, clientY: 5 });
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return (popup.mock.calls[0][0] as { label?: string }[]).map((item) => item.label);
|
||||
const menu = await screen.findByRole('menu');
|
||||
return within(menu).getAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? undefined);
|
||||
}
|
||||
|
||||
describe('the reader', () => {
|
||||
|
||||
@@ -106,11 +106,13 @@ describe('Remove from this folder in the message menu', () => {
|
||||
const onBatchRemoveFromFolder = vi.fn();
|
||||
const batch = { isMultiSelect: true, selectedCount: 2, onBatchRemoveFromFolder };
|
||||
const { unmount } = renderMenu({ ...batch, batchEmails: [inBoth, inboxOnly] });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull();
|
||||
unmount();
|
||||
|
||||
const both = { ...inBoth, id: 'e3' } as Email;
|
||||
renderMenu({ ...batch, batchEmails: [inBoth, both] });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
fireEvent.click(screen.getByTestId('ctx-remove-from-folder'));
|
||||
expect(onBatchRemoveFromFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ function renderMenu(props: Partial<MenuProps> = {}) {
|
||||
describe('EmailContextMenu batch forward as attachment', () => {
|
||||
it('shows the batch header and batch forward item when the right-clicked email is in a multi-selection', () => {
|
||||
renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeInTheDocument();
|
||||
expect(screen.getByText('items_selected')).toBeInTheDocument();
|
||||
});
|
||||
@@ -60,6 +61,7 @@ describe('EmailContextMenu batch forward as attachment', () => {
|
||||
it('clicking the batch item invokes onBatchForwardAsAttachment and closes the menu', () => {
|
||||
const onBatchForwardAsAttachment = vi.fn();
|
||||
const { onClose } = renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
fireEvent.click(screen.getByTestId('ctx-batch-forward-as-attachment'));
|
||||
expect(onBatchForwardAsAttachment).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
@@ -67,6 +69,7 @@ describe('EmailContextMenu batch forward as attachment', () => {
|
||||
|
||||
it('hides the single-message forward-as-attachment item in batch mode', () => {
|
||||
renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
// vitest's next-intl mock renders labels as raw keys; the singular key
|
||||
// must be absent while the plural batch key is present.
|
||||
expect(screen.queryByText('forward_as_attachment')).toBeNull();
|
||||
@@ -96,6 +99,7 @@ describe('EmailContextMenu batch forward as attachment', () => {
|
||||
|
||||
it('disables the batch item when no handler is passed', () => {
|
||||
renderMenu({ isMultiSelect: true, selectedCount: 3 });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect((screen.getByTestId('ctx-batch-forward-as-attachment') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -61,6 +61,7 @@ describe('selection bar', () => {
|
||||
fireEvent.click(screen.getByTestId('batch-more'));
|
||||
expect(screen.getByText('items_selected')).toBeTruthy();
|
||||
expect(screen.getByTestId('ctx-move-to')).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -99,7 +100,8 @@ describe('selection bar: Move, read state and the reading pane', () => {
|
||||
const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement;
|
||||
expect(pane).not.toBeNull();
|
||||
expect(pane.textContent).toContain('selected_conversations');
|
||||
for (const id of ['archive', 'delete', 'move', 'mark-read']) {
|
||||
// All read: the pane offers Mark as unread, as the selection's menu does.
|
||||
for (const id of ['archive', 'delete', 'move', 'mark-unread', 'star', 'tag', 'spam', 'more']) {
|
||||
expect(pane.querySelector(`[data-testid="pane-batch-${id}"]`)).not.toBeNull();
|
||||
}
|
||||
fireEvent.click(pane.querySelector('[data-testid="pane-batch-archive"]')!);
|
||||
@@ -136,15 +138,71 @@ describe('the reading pane for a selection (mail-p2-15)', () => {
|
||||
document.body.appendChild(host);
|
||||
render(<EmailList emails={useEmailStore.getState().emails} selectionPaneHost={host} />);
|
||||
const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement;
|
||||
for (const id of ['archive', 'delete', 'move', 'mark-read', 'tag']) {
|
||||
const button = pane.querySelector(`[data-testid="pane-batch-${id}"]`) as HTMLElement;
|
||||
expect(button.textContent?.trim()).toBe(button.getAttribute('aria-label'));
|
||||
for (const button of Array.from(pane.querySelectorAll('button'))) {
|
||||
expect(button.textContent?.trim()).not.toBe('');
|
||||
}
|
||||
expect(screen.getByTestId('batch-archive').textContent).toBe('');
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// The selection view shows the selection menu's top level as buttons, in its order,
|
||||
// with More actions as a labelled button, not a bare icon.
|
||||
describe('the selection view', () => {
|
||||
const paneLabels = (pane: HTMLElement) =>
|
||||
Array.from(pane.querySelectorAll('[data-testid="selection-actions"] button'))
|
||||
.map((button) => button.querySelector('[data-selection-label]')?.textContent ?? '');
|
||||
|
||||
it('has the same buttons as the selection menu has entries, in the same order', () => {
|
||||
const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email));
|
||||
useEmailStore.setState({ emails });
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
render(<EmailList emails={emails} selectionPaneHost={host} />);
|
||||
const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement;
|
||||
fireEvent.click(screen.getByTestId('batch-more'));
|
||||
const menu = screen.getAllByRole('menu').find((m) => m.textContent?.includes('items_selected'))!;
|
||||
const menuTop = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.filter((row) => row.parentElement?.closest('[role="menu"]') === menu)
|
||||
.map((row) => row.querySelector('[data-menu-label]')?.textContent ?? '');
|
||||
expect(paneLabels(pane)).toEqual(menuTop);
|
||||
expect(paneLabels(pane)).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']);
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('opens what the menu has under More actions from its More actions button', () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
render(<EmailList emails={useEmailStore.getState().emails} selectionPaneHost={host} />);
|
||||
fireEvent.click(host.querySelector('[data-testid="pane-batch-more"]')!);
|
||||
const menu = screen.getByRole('menu', { name: 'more_actions' });
|
||||
expect(within(menu).getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy();
|
||||
expect(within(menu).queryByTestId('ctx-archive')).toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const lineLengths = (width: number) => {
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width);
|
||||
const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email));
|
||||
useEmailStore.setState({ emails });
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const { unmount } = render(<EmailList emails={emails} selectionPaneHost={host} />);
|
||||
const lengths = Array.from(host.querySelectorAll('[data-selection-line]')).map((line) => line.querySelectorAll('button').length);
|
||||
unmount();
|
||||
host.remove();
|
||||
vi.restoreAllMocks();
|
||||
return lengths;
|
||||
};
|
||||
|
||||
it('leaves no button alone on a line in a narrow pane', () => {
|
||||
// Nine buttons, room for three a line: three even lines, not 3 + 3 + 2 + 1.
|
||||
expect(lineLengths(560)).toEqual([3, 3, 3]);
|
||||
// Room for two: pairs, and More actions across the width at the end.
|
||||
expect(lineLengths(360)).toEqual([2, 2, 2, 2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection bar and rows from the keyboard', () => {
|
||||
it('keeps the collapsed bar out of the Tab order', () => {
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set() });
|
||||
|
||||
@@ -277,6 +277,16 @@ describe('message-row-actions', () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts them under More actions in the right-click menu of a row, in the browser and the desktop app alike', async () => {
|
||||
act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
|
||||
menu({ rowMenu: true });
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
const more = screen.getByRole('menu', { name: 'more_actions' });
|
||||
await act(async () => { fireEvent.click(within(more).getByRole('menuitem', { name: 'Later' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
|
||||
});
|
||||
|
||||
it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => {
|
||||
const { unmount } = menu();
|
||||
expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu';
|
||||
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMoveMenu } from '@/lib/move-menu';
|
||||
import { MoveFolderList } from '../move-target-list';
|
||||
|
||||
// The desktop app shows the Move menu natively, read back from the HTML one.
|
||||
// A native menu can't indent and the account headers used to be plain divs,
|
||||
// so it showed every account's Inbox, Sent, Spam and Trash in one flat list
|
||||
// with nothing to tell them apart.
|
||||
|
||||
const writable = { mayAddItems: true } as Mailbox['myRights'];
|
||||
const box = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
({
|
||||
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra,
|
||||
}) as Mailbox;
|
||||
const shared = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra });
|
||||
|
||||
const mailboxes = [
|
||||
box('inbox', 'Inbox', { role: 'inbox' }),
|
||||
box('trash', 'Trash', { role: 'trash' }),
|
||||
box('work', 'Work'),
|
||||
box('clients', 'Clients', { parentId: 'work' }),
|
||||
box('acme', 'Acme', { parentId: 'clients' }),
|
||||
box('rd', 'R&D'),
|
||||
shared('inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('trash', 'Trash', { role: 'trash' }),
|
||||
];
|
||||
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>(() => {}));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
async function nativeMoveMenu(): Promise<DesktopNativeMenuItem[]> {
|
||||
const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] });
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
<ContextMenuSubMenu label="Move to">
|
||||
<MoveFolderList model={model} onPick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[];
|
||||
return items[0].submenu!;
|
||||
}
|
||||
|
||||
const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type);
|
||||
|
||||
describe('the Move menu shown natively', () => {
|
||||
it('has the recent folders under a header, then the account\'s own folders', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
expect(labels(move)).toEqual([
|
||||
'recent', 'Work / Clients / Acme', 'separator',
|
||||
'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work',
|
||||
'separator', 'team@example.org',
|
||||
]);
|
||||
expect(move[0].enabled).toBe(false);
|
||||
// The open folder is there, greyed.
|
||||
expect(move[3].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nests subfolders as submenus, the folder itself first', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
const work = move.find((item) => item.label === 'Work')!;
|
||||
expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']);
|
||||
expect(work.submenu![0].id).toBeTruthy();
|
||||
const clients = work.submenu![2];
|
||||
expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']);
|
||||
});
|
||||
|
||||
it('puts another account\'s folders in a submenu under its name', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
const team = move.find((item) => item.label === 'team@example.org')!;
|
||||
expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,7 @@ describe('the row menu', () => {
|
||||
it('reports a whole selection, one message each', () => {
|
||||
setAddress('soc@example.org');
|
||||
rowMenu('inbox', { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other], onBatchMarkAsSpam: vi.fn() });
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
fireEvent.click(screen.getByTestId('ctx-batch-report-phishing'));
|
||||
expect(usePhishingReportStore.getState().pending?.emails.map((e) => e.id)).toEqual(['e1', 'e2']);
|
||||
});
|
||||
|
||||
@@ -30,47 +30,50 @@ describe('the right-click menu in the desktop app', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
|
||||
const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
|
||||
// The same HTML menu as everywhere else in the app (it was a native menu).
|
||||
const openMenu = async (overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {} } } as never;
|
||||
const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides });
|
||||
fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 });
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { props, popup };
|
||||
const menu = await screen.findByRole('menu');
|
||||
const labels = within(menu).queryAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? '');
|
||||
const pick = (label: string) => fireEvent.click(within(menu).getAllByRole('menuitem').find((item) => item.querySelector('[data-menu-label]')?.textContent === label)!);
|
||||
return { props, labels, pick };
|
||||
};
|
||||
|
||||
it('has Open, Save as and Copy, natively', async () => {
|
||||
const { popup } = await openMenu(() => null);
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']);
|
||||
it('has Open, Save as and Copy', async () => {
|
||||
const { labels } = await openMenu();
|
||||
expect(labels).toEqual(['open', 'save_as', 'copy']);
|
||||
});
|
||||
|
||||
it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => {
|
||||
const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!);
|
||||
const { props, pick } = await openMenu();
|
||||
pick(label);
|
||||
await waitFor(() => expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler]).toHaveBeenCalledTimes(1));
|
||||
expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' });
|
||||
});
|
||||
|
||||
it('with downloads off there\'s nothing to save or copy', async () => {
|
||||
const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true });
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
|
||||
it('with downloads off there is nothing to save or copy', async () => {
|
||||
const { labels } = await openMenu({ downloadsOff: true, opensPreview: () => true });
|
||||
expect(labels).toEqual(['open']);
|
||||
});
|
||||
|
||||
it('Edit shows when the file can be edited and runs the action', async () => {
|
||||
const onEdit = vi.fn();
|
||||
const { props, popup } = await openMenu((items) => items.find((i) => i.label === 'edit')?.id ?? null, { onEdit, canEdit: () => true });
|
||||
const labels = popup.mock.calls[0][0].flatMap((i: { label?: string }) => (i.label ? [i.label] : []));
|
||||
const { props, labels, pick } = await openMenu({ onEdit, canEdit: () => true });
|
||||
expect(labels).toEqual(['open', 'save_as', 'edit', 'copy']);
|
||||
pick('edit');
|
||||
await waitFor(() => expect(onEdit).toHaveBeenCalledWith(props.attachments[0]));
|
||||
});
|
||||
|
||||
it('Edit stays out for a file it cannot take', async () => {
|
||||
const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => false });
|
||||
expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit');
|
||||
const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => false });
|
||||
expect(labels).not.toContain('edit');
|
||||
});
|
||||
|
||||
it('Edit stays out with downloads off, whatever else says yes', async () => {
|
||||
const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => true, downloadsOff: true });
|
||||
expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit');
|
||||
const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => true, downloadsOff: true });
|
||||
expect(labels).not.toContain('edit');
|
||||
});
|
||||
|
||||
it('a browser keeps its own menu', async () => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
|
||||
// Rules needs a Sieve account; its entry stands in here.
|
||||
vi.mock('../rules-menu', () => ({
|
||||
RulesContextSubMenu: () => <div role="menuitem" data-testid="ctx-rules"><span data-menu-label="">rules.title</span></div>,
|
||||
}));
|
||||
|
||||
import { EmailContextMenu } from '../email-context-menu';
|
||||
|
||||
// The message's right-click menu is short everywhere: the browser and the desktop app
|
||||
// show the same HTML menu (the desktop app had a native copy, without icons and with
|
||||
// Chromium's key names), and the rarely used entries are under More actions. A
|
||||
// selection gets the same structure without what needs one message.
|
||||
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
|
||||
hasAttachment: false, size: 100,
|
||||
} as unknown as Email;
|
||||
const other = { ...email, id: 'e2', keywords: {} } as unknown as Email;
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
type MenuProps = React.ComponentProps<typeof EmailContextMenu>;
|
||||
|
||||
function renderMenu(props: Partial<MenuProps> = {}) {
|
||||
return render(
|
||||
<EmailContextMenu
|
||||
rowMenu
|
||||
email={email}
|
||||
position={{ x: 10, y: 10 }}
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
onReply={vi.fn()}
|
||||
onReplyAll={vi.fn()}
|
||||
onForward={vi.fn()}
|
||||
onForwardAsAttachment={vi.fn()}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onMarkAsRead={vi.fn()}
|
||||
onToggleStar={vi.fn()}
|
||||
onTogglePinned={vi.fn()}
|
||||
onSetTag={vi.fn()}
|
||||
onMoveToMailbox={vi.fn()}
|
||||
onMarkAsSpam={vi.fn()}
|
||||
onOpenInWindow={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
onPrint={vi.fn()}
|
||||
onViewSource={vi.fn()}
|
||||
onBatchArchive={vi.fn()}
|
||||
onBatchDelete={vi.fn()}
|
||||
onBatchMarkAsRead={vi.fn()}
|
||||
onBatchSetStarred={vi.fn()}
|
||||
onBatchToggleTag={vi.fn()}
|
||||
onBatchMoveToMailbox={vi.fn()}
|
||||
onBatchMarkAsSpam={vi.fn()}
|
||||
onBatchForwardAsAttachment={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The labels of a menu's own rows, not those of its submenus. */
|
||||
function labels(menu: HTMLElement): string[] {
|
||||
return Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.filter((row) => row.parentElement?.closest('[role="menu"]') === menu)
|
||||
.map((row) => row.querySelector('[data-menu-label]')?.textContent ?? '');
|
||||
}
|
||||
|
||||
const ROW_TOP = ['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions'];
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("a message row's right-click menu", () => {
|
||||
it('has the short list at the top in the browser', () => {
|
||||
renderMenu();
|
||||
expect(labels(screen.getAllByRole('menu')[0])).toEqual(ROW_TOP);
|
||||
});
|
||||
|
||||
it('is the same HTML menu in the desktop app, on screen, with no native copy', async () => {
|
||||
const popup = vi.fn(async () => null);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
|
||||
renderMenu();
|
||||
const menu = screen.getAllByRole('menu')[0];
|
||||
await waitFor(() => expect(menu.style.visibility).toBe('visible'));
|
||||
expect(labels(menu)).toEqual(ROW_TOP);
|
||||
// Icons on the rows, as in the browser.
|
||||
expect(within(menu).getByTestId('ctx-delete').querySelector('svg')).not.toBeNull();
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the rest under More actions, in the order asked for', () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual([
|
||||
'forward_as_attachment', 'copy_message', 'copy_conversation', 'rules.title', 'pin', 'export_email', 'print', 'view_source',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a selection's right-click menu", () => {
|
||||
const selection = { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other] };
|
||||
|
||||
it('has the same structure, without what needs one message', () => {
|
||||
renderMenu(selection);
|
||||
const top = labels(screen.getAllByRole('menu')[0]);
|
||||
expect(top).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']);
|
||||
for (const single of ['reply', 'reply_all', 'forward', 'open_in_window']) expect(top).not.toContain(single);
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual(['forward_as_attachments', 'rules.title']);
|
||||
});
|
||||
|
||||
it('stars the whole selection', () => {
|
||||
const onBatchSetStarred = vi.fn();
|
||||
renderMenu({ ...selection, onBatchSetStarred });
|
||||
fireEvent.click(screen.getByTestId('ctx-star'));
|
||||
expect(onBatchSetStarred).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('only More actions, for the selection view\'s button', () => {
|
||||
renderMenu({ ...selection, section: 'more' });
|
||||
expect(labels(screen.getAllByRole('menu')[0])).toEqual(['forward_as_attachments', 'rules.title']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Star, StarOff, Archive } from '@/components/icons';
|
||||
import { SelectionView } from '../selection-view';
|
||||
import type { MessageActionEntry } from '../message-actions';
|
||||
|
||||
// After an action the focus stays where it was in the selection view: Star turning into
|
||||
// Unstar is the same button.
|
||||
|
||||
const entries = (starred: boolean): MessageActionEntry[] => [
|
||||
{ kind: 'item', key: 'archive', icon: Archive, label: 'archive', run: vi.fn(), testId: 'ctx-archive' },
|
||||
starred
|
||||
? { kind: 'item', key: 'star', icon: StarOff, label: 'unstar', run: vi.fn(), testId: 'ctx-unstar' }
|
||||
: { kind: 'item', key: 'star', icon: Star, label: 'star', run: vi.fn(), testId: 'ctx-star' },
|
||||
];
|
||||
|
||||
const props = {
|
||||
title: '2 selected',
|
||||
unread: 0,
|
||||
shortcutOf: () => undefined,
|
||||
onMove: vi.fn(),
|
||||
onMore: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
tags: { selectedIds: [], partialIds: [], scope: null, onToggle: vi.fn() },
|
||||
};
|
||||
|
||||
describe('the selection view after an action', () => {
|
||||
it('keeps the focus on Star when it becomes Unstar', () => {
|
||||
const { rerender } = render(<SelectionView {...props} entries={entries(false)} />);
|
||||
const star = screen.getByTestId('pane-batch-star');
|
||||
star.focus();
|
||||
rerender(<SelectionView {...props} entries={entries(true)} />);
|
||||
expect(document.activeElement).toBe(screen.getByTestId('pane-batch-unstar'));
|
||||
expect(screen.getByTestId('pane-batch-unstar')).toBe(star);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { selectionRows } from '../selection-view';
|
||||
|
||||
// The selection view's buttons go in even lines: none left alone on the last one.
|
||||
|
||||
describe('selectionRows', () => {
|
||||
it('puts everything on one line when it fits', () => {
|
||||
expect(selectionRows(4, 4)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('evens out the lines instead of leaving one button alone', () => {
|
||||
expect(selectionRows(7, 3)).toEqual([3, 2, 2]);
|
||||
expect(selectionRows(9, 4)).toEqual([3, 3, 3]);
|
||||
expect(selectionRows(5, 4)).toEqual([3, 2]);
|
||||
});
|
||||
|
||||
it('with room for two, an odd count ends in one line across the width, not a column', () => {
|
||||
expect(selectionRows(9, 2)).toEqual([2, 2, 2, 2, 1]);
|
||||
});
|
||||
|
||||
it('never leaves one button alone at the end of a ragged wrap, for any count and width', () => {
|
||||
for (let count = 2; count <= 12; count++) {
|
||||
for (let perLine = 1; perLine <= 6; perLine++) {
|
||||
const lines = selectionRows(count, perLine);
|
||||
expect(lines.reduce((a, b) => a + b, 0)).toBe(count);
|
||||
expect(Math.max(...lines)).toBeLessThanOrEqual(Math.max(1, perLine));
|
||||
// A line of one only in a column, or last under lines of two.
|
||||
const ones = lines.map((n, i) => (n === 1 ? i : -1)).filter((i) => i >= 0);
|
||||
if (ones.length && lines.some((n) => n > 1)) {
|
||||
expect(ones).toEqual([lines.length - 1]);
|
||||
expect(lines.slice(0, -1).every((n) => n === 2)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { Tag } from "@/components/icons";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
const TAG_PICKER_WIDTH = 224;
|
||||
|
||||
/**
|
||||
* The tag entry of the selection toolbar: opens the shared tag picker for
|
||||
* every selected message at once. (#1077)
|
||||
*
|
||||
* The picker is portalled to the body because the toolbar clips its overflow
|
||||
* to animate open and shut.
|
||||
* every selected message at once (#1077), in the app's menu under the button.
|
||||
*/
|
||||
export function BatchTagButton({
|
||||
title,
|
||||
@@ -44,63 +38,15 @@ export function BatchTagButton({
|
||||
/** Name the button in words beside the icon, as the reading pane does (mail-p2-15). */
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const close = useCallback(() => setPos(null), []);
|
||||
const isOpen = pos !== null && active;
|
||||
// The picker sits at the end of <body>: the focus moves into it on open,
|
||||
// the arrow keys walk the tags, and the focus goes back to the button on
|
||||
// close (Escape, Tab, a pick that closes it).
|
||||
const { menuRef: pickerRef, onKeyDown: onMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: isOpen,
|
||||
onClose: close,
|
||||
triggerRef: buttonRef,
|
||||
});
|
||||
const onPickerKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
// Home and End belong to the text fields (filter, new tag).
|
||||
if ((event.key === "Home" || event.key === "End") && event.target instanceof HTMLInputElement) return;
|
||||
onMenuKeyDown(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pos) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
// The anchor is left to its own click handler, which toggles the picker
|
||||
// shut - closing here first would let that click reopen it.
|
||||
if (buttonRef.current?.contains(target)) return;
|
||||
if (!pickerRef.current?.contains(target)) close();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [pos, close, pickerRef]);
|
||||
const menu = useContextMenu<null>();
|
||||
const isOpen = menu.contextMenu.isOpen && active;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
label={title}
|
||||
data-testid={testId}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setPos((open) =>
|
||||
open
|
||||
? null
|
||||
: {
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(8, Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
onClick={(event) => (isOpen ? menu.closeContextMenu() : menu.openContextMenuFrom(event.currentTarget, null, "start"))}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled}
|
||||
@@ -109,21 +55,11 @@ export function BatchTagButton({
|
||||
<Tag />
|
||||
{showLabel && <span data-selection-label="">{title}</span>}
|
||||
</IconButton>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
data-testid="batch-tag-picker"
|
||||
role="menu"
|
||||
aria-label={title}
|
||||
onKeyDown={onPickerKeyDown}
|
||||
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} scope={scope} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<ContextMenu ref={menu.menuRef} isOpen={isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu} label={title}>
|
||||
<div data-testid="batch-tag-picker" className="w-56 max-w-[18rem]">
|
||||
<TagPicker selectedIds={selectedIds} partialIds={partialIds} onToggle={onToggle} scope={scope} />
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -112,17 +113,11 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
|
||||
data-testid={testId}
|
||||
className={cn(CONTEXT_MENU_ITEM_CLASS, "items-start")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border",
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/50"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && <Check className="w-3 h-3" />}
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
{checked && <Check className="w-4 h-4" />}
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block">{children}</span>
|
||||
<span className="block" data-menu-label="">{children}</span>
|
||||
{description && <span className="block text-xs text-muted-foreground">{description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
@@ -147,16 +142,10 @@ export function ComposerToolbarMenuRadioItem({ checked, onSelect, testId, childr
|
||||
data-testid={testId}
|
||||
className={CONTEXT_MENU_ITEM_CLASS}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
||||
checked ? "border-primary" : "border-muted-foreground/50"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && <span className="h-2 w-2 rounded-full bg-primary" />}
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
{checked && <Check className="w-4 h-4" />}
|
||||
</span>
|
||||
<span className="flex-1">{children}</span>
|
||||
<span className="flex-1" data-menu-label="">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -192,7 +181,7 @@ export function ComposerPluginToolbarButtons({ surface, context, alwaysVisible =
|
||||
<>
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label;
|
||||
const tip = action.shortcut ? `${action.label} (${formatShortcutCombo(action.shortcut)})` : action.label;
|
||||
return (
|
||||
<Button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
BookUser,
|
||||
Lock,
|
||||
CloudOff,
|
||||
Pencil,
|
||||
UserPen,
|
||||
ArrowLeftRight,
|
||||
} from "@/components/icons";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
|
||||
@@ -188,7 +191,11 @@ import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { isAppLockedNow } from "@/lib/app-lock/state";
|
||||
import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { getDesktopBridge, isDesktopApp } from "@/lib/desktop/bridge";
|
||||
import { askInWindow } from "@/lib/desktop/dialog-window";
|
||||
import { readRecentFile, RECENT_FILE_MAX_BYTES, useRecentFilesOffered } from "@/lib/desktop/recent-files";
|
||||
import type { DesktopRecentFile } from "@/lib/desktop/types";
|
||||
import { RecentFilesMenuSection } from "@/components/email/recent-files-menu-section";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { noteComposerLanguage } from "@/lib/desktop/languages";
|
||||
import { noteOpenDraft } from "@/lib/desktop/window-restore";
|
||||
@@ -650,6 +657,7 @@ export function EmailComposer({
|
||||
const tEdit = useTranslations('desktopEdit');
|
||||
const tOfflineDrafts = useTranslations('offlineDrafts');
|
||||
const tOffline = useTranslations('offline');
|
||||
const tRecent = useTranslations('desktopAttachRecent');
|
||||
const tOfflineNotes = useTranslations('offlineConflicts');
|
||||
const tSendGuards = useTranslations('send_guards');
|
||||
const tWords = useTranslations('compose_words');
|
||||
@@ -2778,6 +2786,20 @@ export function EmailComposer({
|
||||
void addFiles(initialFiles, { handedIn: true });
|
||||
}, [client, initialFiles, addFiles]);
|
||||
|
||||
// The Attach menu's recent files, in the desktop app (lib/desktop/recent-files.ts). The
|
||||
// shell reads the file; from there it's a file like one from the file dialog.
|
||||
const recentOffered = useRecentFilesOffered();
|
||||
const attachRecentFile = useCallback(async (file: DesktopRecentFile) => {
|
||||
const result = await readRecentFile(file.id);
|
||||
if (result.ok) {
|
||||
await addFiles([result.file]);
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'missing') toast.error(tRecent('missing', { name: file.name }));
|
||||
else if (result.reason === 'too-large') toast.error(tRecent('too_large', { name: file.name, max: formatFileSize(RECENT_FILE_MAX_BYTES) }));
|
||||
else if (result.reason === 'failed') toast.error(tRecent('failed', { name: file.name }));
|
||||
}, [addFiles, tRecent]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
await addFiles(Array.from(event.target.files));
|
||||
@@ -4862,15 +4884,39 @@ export function EmailComposer({
|
||||
emitClose();
|
||||
};
|
||||
|
||||
/** The question before closing or discarding: the OS's own in the desktop app. */
|
||||
/**
|
||||
* The question before closing or discarding. In the desktop app it is a window of its
|
||||
* own; an older shell asks with the OS's message box.
|
||||
*/
|
||||
const openCloseDialog = (kind: 'discard' | 'close') => {
|
||||
const dialogs = getDesktopBridge()?.dialogs;
|
||||
if (!dialogs) {
|
||||
const inPage = () => {
|
||||
setCloseDialogKind(kind);
|
||||
setShowCloseDialog(true);
|
||||
};
|
||||
const discarding = kind === 'discard';
|
||||
const asked = askInWindow({
|
||||
title: discarding ? t('discard_draft_title') : t('close_draft_title'),
|
||||
message: discarding ? t('discard_draft_message') : tUx('save_failed_close'),
|
||||
buttons: discarding
|
||||
? [{ label: t('cancel'), variant: 'secondary' }, { label: t('discard'), variant: 'destructive' }]
|
||||
: [{ label: t('cancel'), variant: 'secondary' }, { label: t('discard'), variant: 'destructive' }, { label: tUx('try_again') }],
|
||||
defaultIndex: discarding ? 0 : 2,
|
||||
});
|
||||
if (asked) {
|
||||
void asked.answer.then((answer) => {
|
||||
const button = answer && answer !== 'failed' && 'button' in answer ? answer.button : -1;
|
||||
if (answer === 'failed') inPage();
|
||||
else if (button === 1) handleDiscardAndClose();
|
||||
else if (button === 2) void handleSaveDraftAndClose({ fromClose: true });
|
||||
else afterCloseRef.current = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dialogs = getDesktopBridge()?.dialogs;
|
||||
if (!dialogs) {
|
||||
inPage();
|
||||
return;
|
||||
}
|
||||
const discarding = kind === 'discard';
|
||||
const buttons = discarding ? [t('discard'), t('cancel')] : [tUx('try_again'), t('discard'), t('cancel')];
|
||||
void dialogs.confirm({
|
||||
type: 'warning',
|
||||
@@ -5942,14 +5988,19 @@ export function EmailComposer({
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
{(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
|
||||
{recentOffered || (filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
|
||||
<ComposerToolbarMenu icon={Paperclip} label={t('attach')} testId="composer-attach-menu">
|
||||
{(close) => <>
|
||||
{/* As Outlook's: the recent files first, one click attaches one and closes. */}
|
||||
{recentOffered && (
|
||||
<RecentFilesMenuSection onPick={(file) => { close(); void attachRecentFile(file); }} />
|
||||
)}
|
||||
<ComposerToolbarMenuItem
|
||||
icon={Upload}
|
||||
onSelect={() => { close(); fileInputRef.current?.click(); }}
|
||||
testId="composer-attach-from-device"
|
||||
>
|
||||
{t('attach_from_device')}
|
||||
{isDesktopApp() ? tRecent('browse') : t('attach_from_device')}
|
||||
</ComposerToolbarMenuItem>
|
||||
{filesEnabled && composerClient?.supportsFiles?.() && (
|
||||
<ComposerToolbarMenuItem
|
||||
@@ -5957,7 +6008,7 @@ export function EmailComposer({
|
||||
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
|
||||
testId="composer-attach-from-files"
|
||||
>
|
||||
{t('attach_from_files')}
|
||||
{isDesktopApp() ? tRecent('from_files') : t('attach_from_files')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
<ComposerPluginMenuItems surface="composer-attach-menu" context={composerActionContext} onSelect={close} separator />
|
||||
@@ -7124,14 +7175,15 @@ export function RecipientChipInput({
|
||||
</div>
|
||||
<ContextMenuSeparator />
|
||||
{!contextMenu.data.recipient.group && (
|
||||
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
<ContextMenuItem icon={Pencil} label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||
)}
|
||||
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
|
||||
<ContextMenuItem icon={UserPen} label={t('recipient_edit_name')} onClick={handleEditName} />
|
||||
<ContextMenuSeparator />
|
||||
{/* Moving between fields without dragging (WCAG 2.5.7). */}
|
||||
{(['to', 'cc', 'bcc'] as const).filter((target) => target !== field).map((target) => (
|
||||
<ContextMenuItem
|
||||
key={target}
|
||||
icon={ArrowLeftRight}
|
||||
testId={`recipient-move-${target}`}
|
||||
label={t('recipient_move_to', { field: fieldNames[target] })}
|
||||
onClick={() => {
|
||||
@@ -7143,7 +7195,7 @@ export function RecipientChipInput({
|
||||
/>
|
||||
))}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem label={t('recipient_remove')} onClick={() => {
|
||||
<ContextMenuItem icon={X} label={t('recipient_remove')} onClick={() => {
|
||||
if (contextMenu.data) {
|
||||
handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { spamApplicableIn } from "@/lib/spam-applicable";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store";
|
||||
import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
|
||||
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import {
|
||||
@@ -17,22 +15,9 @@ import {
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
Trash2,
|
||||
Archive,
|
||||
FolderInput,
|
||||
FolderX,
|
||||
Tag,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Paperclip,
|
||||
Copy,
|
||||
Fish,
|
||||
MoreHorizontal,
|
||||
Undo,
|
||||
} from "@/components/icons";
|
||||
import { tidySeparators, useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
|
||||
import { rowMenuSections, useMessageActionList, useSelectionActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
|
||||
import { restoreTarget, restoreTargetForAll } from "@/lib/trash-origin";
|
||||
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
|
||||
@@ -43,8 +28,6 @@ import { useConnectionState } from "@/hooks/use-connection-state";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { RulesContextSubMenu } from "./rules-menu";
|
||||
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { usePhishingReport } from "@/hooks/use-phishing-report";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -104,6 +87,8 @@ interface EmailContextMenuProps {
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
/** Stars the whole selection, or takes the star off every message. */
|
||||
onBatchSetStarred?: (starred: boolean) => void;
|
||||
/** Tags every selected message carries, and those only some of them do. */
|
||||
batchTagIds?: string[];
|
||||
batchPartialTagIds?: string[];
|
||||
@@ -152,21 +137,13 @@ interface EmailContextMenuProps {
|
||||
* "More actions". The reader's ⋮ is that "more" already, so it shows them all.
|
||||
*/
|
||||
rowMenu?: boolean;
|
||||
/**
|
||||
* "more": only what a selection's menu has under "More actions", for the
|
||||
* selection view's More actions button.
|
||||
*/
|
||||
section?: "all" | "more";
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row's menu shows at its top level, as Outlook and Gmail have it. The rest
|
||||
* goes under "More actions".
|
||||
*/
|
||||
export const ROW_MENU_KEYS = new Set<MessageActionKey>([
|
||||
"reschedule", "cancel_and_edit", "cancel_send", "edit_draft",
|
||||
"reply", "reply_all", "forward",
|
||||
"restore", "archive", "delete", "move",
|
||||
"read", "star", "tag",
|
||||
"spam",
|
||||
"open_window",
|
||||
]);
|
||||
|
||||
/** Entries that change the message on the server, or queue the change offline. */
|
||||
const CHANGES_MAIL = new Set<MessageActionKey>([
|
||||
"archive", "delete", "read", "remove_from_folder", "star", "pin", "spam",
|
||||
@@ -201,6 +178,7 @@ export function EmailContextMenu({
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onBatchMarkAsRead,
|
||||
onBatchSetStarred,
|
||||
batchTagIds,
|
||||
batchPartialTagIds,
|
||||
batchTagScope = null,
|
||||
@@ -230,33 +208,22 @@ export function EmailContextMenu({
|
||||
extraEntries,
|
||||
label,
|
||||
rowMenu = false,
|
||||
section = "all",
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
const tRecover = useTranslations("recover_deleted");
|
||||
const shortcutOf = useMessageActionShortcut();
|
||||
// Offline, a change to a message waits in the outbox only where the account keeps
|
||||
// its mail on this device; otherwise it would fail, so it's greyed out. Rules and
|
||||
// copies to another account always need the server.
|
||||
const { offline, canChangeMail } = useConnectionState();
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
const archiveActionEnabled = useArchiveActionEnabled();
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
// The row's account names its tags; a selection uses batchTagScope.
|
||||
const tagScope = useEmailTagScope(email);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
// in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out.
|
||||
const spamApplicable = spamApplicableIn(currentMailboxRole);
|
||||
const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole });
|
||||
const tActions = useTranslations("reader_actions");
|
||||
const isScheduled = email.isScheduled === true;
|
||||
// One report per selected message, behind one confirm (enterprise pack).
|
||||
const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole);
|
||||
const tPhishing = useTranslations("phishing_report");
|
||||
// "Remove from this folder" needs one open folder and messages that are
|
||||
// also in another one; for a selection, every selected message must be.
|
||||
const folderToLeave = useEmailStore(removalFolder);
|
||||
@@ -383,91 +350,7 @@ export function EmailContextMenu({
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const archiveItem = archiveActionEnabled && (
|
||||
<>
|
||||
{/* Archive */}
|
||||
<ContextMenuItem
|
||||
icon={Archive}
|
||||
label={t("archive")}
|
||||
shortcut={shortcutOf("archive")}
|
||||
onClick={() => handleAction(onBatchArchive!)}
|
||||
disabled={!onBatchArchive || !canChangeMail}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const deleteItem = (
|
||||
<>
|
||||
{/* Delete */}
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={deleteForever ? tActions("delete_forever") : t("delete")}
|
||||
testId="ctx-delete"
|
||||
shortcut={shortcutOf("delete")}
|
||||
onClick={() => handleAction(onBatchDelete!)}
|
||||
disabled={!onBatchDelete || !canChangeMail}
|
||||
destructive={!mountainView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
// A selection of read and unread mail gets both; otherwise the one that changes something.
|
||||
const selection = batchEmails?.length ? batchEmails : [email];
|
||||
const someUnread = selection.some((e) => !e.keywords?.$seen);
|
||||
const someRead = selection.some((e) => !!e.keywords?.$seen);
|
||||
const readItem = (
|
||||
<>
|
||||
{someUnread && (
|
||||
<ContextMenuItem
|
||||
icon={MailOpen}
|
||||
label={t("mark_read")}
|
||||
testId="ctx-mark-read"
|
||||
shortcut={shortcutOf("read", true)}
|
||||
disabled={!canChangeMail}
|
||||
onClick={() => handleAction(() => onBatchMarkAsRead?.(true))}
|
||||
/>
|
||||
)}
|
||||
{someRead && (
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("mark_unread")}
|
||||
testId="ctx-mark-unread"
|
||||
shortcut={shortcutOf("read", false)}
|
||||
disabled={!canChangeMail}
|
||||
onClick={() => handleAction(() => onBatchMarkAsRead?.(false))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const batchRestoreItem = batchRestoreTo ? (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={Undo}
|
||||
label={tRecover("restore")}
|
||||
testId="ctx-restore"
|
||||
disabled={!canChangeMail}
|
||||
onClick={() => handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
) : null;
|
||||
const moveItem = (
|
||||
<>
|
||||
{/* Move to submenu */}
|
||||
{canMoveAnywhere && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to" disabled={!canChangeMail}>
|
||||
{renderMoveList(onBatchMoveToMailbox)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canRemove && (
|
||||
<ContextMenuItem
|
||||
icon={FolderX}
|
||||
label={t("remove_from_folder")}
|
||||
testId="ctx-remove-from-folder"
|
||||
disabled={!canChangeMail}
|
||||
onClick={() => handleAction(onBatchRemoveFromFolder!)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
// Copy to another connected account, keeping the originals.
|
||||
const copyTargetsContent = onCopyToAccount
|
||||
? copyTrees.map((target) => (
|
||||
<div key={target.accountId}>
|
||||
@@ -476,73 +359,33 @@ export function EmailContextMenu({
|
||||
</div>
|
||||
))
|
||||
: null;
|
||||
const copyItem = (
|
||||
<>
|
||||
{/* Copy to another connected account, keeping the originals */}
|
||||
{onCopyToAccount && copyTrees.length > 0 && (
|
||||
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to" disabled={offline}>
|
||||
{copyTargetsContent}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rulesItem = (
|
||||
<>
|
||||
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
|
||||
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} disabled={offline} />
|
||||
</>
|
||||
);
|
||||
const tagItem = (
|
||||
<>
|
||||
{/* Set tag submenu - the whole selection's tags (#1077) */}
|
||||
{onBatchToggleTag && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag" disabled={!canChangeMail}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
scope={batchTagScope}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const spamBlock = (
|
||||
<>
|
||||
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||
{spamApplicable && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||
shortcut={shortcutOf("spam")}
|
||||
onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)}
|
||||
disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail}
|
||||
destructive={!isInJunkFolder && !mountainView}
|
||||
/>
|
||||
{batchPhishing.available && (
|
||||
<ContextMenuItem
|
||||
icon={Fish}
|
||||
label={tPhishing("action")}
|
||||
testId="ctx-batch-report-phishing"
|
||||
onClick={() => handleAction(batchPhishing.report)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const renderSubmenu = (entry: Extract<MessageActionEntry, { kind: "submenu" }>) => {
|
||||
// A selection: the same structure as a row's menu, without what needs one
|
||||
// message (useSelectionActionList), shared with the reading pane's selection view.
|
||||
const selectionLists = useSelectionActionList({
|
||||
emails: showBatchActions ? (batchEmails?.length ? batchEmails : [email]) : [],
|
||||
currentMailboxRole,
|
||||
canMove: canMoveAnywhere,
|
||||
canRemoveFromFolder: canRemove,
|
||||
canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0,
|
||||
canTag: !!onBatchToggleTag,
|
||||
onRestore: batchRestoreTo ? () => onBatchMoveToMailbox?.(batchRestoreTo) : undefined,
|
||||
onArchive: onBatchArchive,
|
||||
onDelete: onBatchDelete,
|
||||
onMarkAsRead: onBatchMarkAsRead,
|
||||
onSetStarred: onBatchSetStarred,
|
||||
onMarkAsSpam: onBatchMarkAsSpam,
|
||||
onUndoSpam: onBatchUndoSpam,
|
||||
onForwardAsAttachment: onBatchForwardAsAttachment,
|
||||
onRemoveFromFolder: onBatchRemoveFromFolder,
|
||||
});
|
||||
|
||||
const renderSubmenu = (entry: Extract<MessageActionEntry, { kind: "submenu" }>, batch: boolean) => {
|
||||
switch (entry.key) {
|
||||
case "move":
|
||||
return (
|
||||
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
|
||||
{renderMoveList(onMoveToMailbox)}
|
||||
{renderMoveList(batch ? onBatchMoveToMailbox : onMoveToMailbox)}
|
||||
</ContextMenuSubMenu>
|
||||
);
|
||||
case "copy_to_account":
|
||||
@@ -555,7 +398,16 @@ export function EmailContextMenu({
|
||||
return (
|
||||
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} scope={tagScope} />
|
||||
{batch ? (
|
||||
<TagPicker
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
scope={batchTagScope}
|
||||
/>
|
||||
) : (
|
||||
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} scope={tagScope} />
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
);
|
||||
@@ -564,43 +416,78 @@ export function EmailContextMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => {
|
||||
const renderEntries = (entries: MessageActionEntry[], keyPrefix = "", batch = false) => entries.map((entry, index) => {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator key={`${keyPrefix}sep-${index}`} />;
|
||||
if (entry.kind === "submenu") return renderSubmenu(entry);
|
||||
if (entry.kind === "submenu") return renderSubmenu(entry, batch);
|
||||
// Mark as read shows Shift+I, Mark as unread U.
|
||||
const unread = entry.key === "read" ? entry.testId === "ctx-mark-read" : isUnread;
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={entry.key}
|
||||
key={`${keyPrefix}${entry.key}-${entry.testId ?? index}`}
|
||||
icon={entry.icon}
|
||||
label={entry.label}
|
||||
testId={entry.testId}
|
||||
disabled={entry.disabled || (!canChangeMail && CHANGES_MAIL.has(entry.key))}
|
||||
destructive={entry.destructive}
|
||||
shortcut={shortcutOf(entry.key, isUnread)}
|
||||
// S stars the open message only, not a selection.
|
||||
shortcut={batch && entry.key === "star" ? undefined : shortcutOf(entry.key, unread)}
|
||||
onClick={() => handleAction(entry.run)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// A row's menu: the usual few, the rest one level down. Not Spam in Spam
|
||||
// and Restore in Trash stay at the top.
|
||||
const rowTop = rowMenu
|
||||
? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key)))
|
||||
: singleEntries;
|
||||
const rowMore = rowMenu
|
||||
? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || !ROW_MENU_KEYS.has(entry.key)))
|
||||
: [];
|
||||
const singleMenu = (
|
||||
|
||||
// Plugins' entries: under "More actions" in a row's or a selection's menu,
|
||||
// at the end of the reader's ⋮ (which is that "more" already).
|
||||
const pluginEntries = (
|
||||
<>
|
||||
{renderEntries(rowTop)}
|
||||
{rowMore.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSubMenu icon={MoreHorizontal} label={tViewer("more_actions")} testId="ctx-more">
|
||||
{renderEntries(rowMore, "more-")}
|
||||
</ContextMenuSubMenu>
|
||||
</>
|
||||
)}
|
||||
{!showBatchActions && rowPlugins && rowPluginBlock}
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</>
|
||||
);
|
||||
const moreMenu = (entries: MessageActionEntry[], batch: boolean) => (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSubMenu icon={MoreHorizontal} label={tViewer("more_actions")} testId="ctx-more">
|
||||
{renderEntries(entries, "more-", batch)}
|
||||
{pluginEntries}
|
||||
</ContextMenuSubMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (showBatchActions) {
|
||||
body = isScheduled ? null : section === "more" ? (
|
||||
<>
|
||||
{renderEntries(selectionLists.more, "more-", true)}
|
||||
{pluginEntries}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ContextMenuHeader>{t("items_selected", { count: selectedCount })}</ContextMenuHeader>
|
||||
{renderEntries(selectionLists.top, "", true)}
|
||||
{moreMenu(selectionLists.more, true)}
|
||||
</>
|
||||
);
|
||||
} else if (rowMenu) {
|
||||
// A row's menu: the usual few, the rest one level down.
|
||||
const { top, more } = rowMenuSections(singleEntries);
|
||||
body = (
|
||||
<>
|
||||
{renderEntries(top)}
|
||||
{moreMenu(more, false)}
|
||||
{extraEntries}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
{renderEntries(singleEntries)}
|
||||
{rowPlugins && rowPluginBlock}
|
||||
{extraEntries}
|
||||
<PluginSlot name="context-menu-email" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@@ -608,65 +495,10 @@ export function EmailContextMenu({
|
||||
isOpen={isOpen}
|
||||
position={position}
|
||||
onClose={onClose}
|
||||
label={label}
|
||||
label={label ?? (section === "more" ? tViewer("more_actions") : undefined)}
|
||||
>
|
||||
{offline && <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader>}
|
||||
{showBatchActions ? (
|
||||
<>
|
||||
<ContextMenuHeader>
|
||||
{t("items_selected", { count: selectedCount })}
|
||||
</ContextMenuHeader>
|
||||
|
||||
{!isScheduled && (<>
|
||||
{batchRestoreItem}
|
||||
{/* Compose-style actions first, above the archive/delete group,
|
||||
mirroring the single menu's ordering. */}
|
||||
<ContextMenuItem
|
||||
icon={Paperclip}
|
||||
label={t("forward_as_attachments")}
|
||||
testId="ctx-batch-forward-as-attachment"
|
||||
onClick={() => handleAction(onBatchForwardAsAttachment!)}
|
||||
disabled={!onBatchForwardAsAttachment}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{mountainView ? (
|
||||
<>
|
||||
{archiveItem}
|
||||
{deleteItem}
|
||||
{readItem}
|
||||
<ContextMenuSeparator />
|
||||
{moveItem}
|
||||
{tagItem}
|
||||
{rulesItem}
|
||||
{copyItem}
|
||||
{spamBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{archiveItem}
|
||||
{deleteItem}
|
||||
<ContextMenuSeparator />
|
||||
{moveItem}
|
||||
{copyItem}
|
||||
{rulesItem}
|
||||
{tagItem}
|
||||
{spamBlock}
|
||||
<ContextMenuSeparator />
|
||||
{readItem}
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{singleMenu}
|
||||
{rowPlugins && rowPluginBlock}
|
||||
{extraEntries}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PluginSlot name="context-menu-email" />
|
||||
{body}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMemo } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { HoverAction } from "@/stores/settings-store";
|
||||
@@ -11,6 +10,8 @@ import { useArchiveActionEnabled, withoutArchiveIfOff } from "@/lib/archive-gate
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { TagPicker } from "@/components/email/tag-picker";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useEmailTagScope } from "@/hooks/use-account-tags";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -100,8 +101,6 @@ const CORNER_CLASSES = {
|
||||
} as const;
|
||||
|
||||
/** Roughly the picker's height; used to flip it above the row near the viewport bottom. */
|
||||
const TAG_PICKER_MAX_HEIGHT = 320;
|
||||
const TAG_PICKER_WIDTH = 224;
|
||||
|
||||
export function EmailHoverActions({
|
||||
email,
|
||||
@@ -142,40 +141,9 @@ export function EmailHoverActions({
|
||||
// pane use. It is portalled to the body because every row sits inside a
|
||||
// `transform`ed virtual item, which would make a `fixed` child scroll with the
|
||||
// list and clip it at the scroller's edge.
|
||||
const [tagPickerPos, setTagPickerPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const pickerRef = useRef<HTMLDivElement | null>(null);
|
||||
const tagButtonRef = useRef<HTMLElement | null>(null);
|
||||
const closeTagPicker = useCallback(() => setTagPickerPos(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagPickerPos) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
// The anchor is left to its own click handler, which toggles the picker
|
||||
// shut - closing here first would let that click reopen it.
|
||||
if (tagButtonRef.current?.contains(target)) return;
|
||||
if (!pickerRef.current?.contains(target)) closeTagPicker();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeTagPicker();
|
||||
};
|
||||
// The anchor moves with the list, so scrolling dismisses rather than chases
|
||||
// it - but the picker's own tag list scrolls too, and that must not close it.
|
||||
const onScroll = (event: Event) => {
|
||||
if (pickerRef.current?.contains(event.target as Node)) return;
|
||||
closeTagPicker();
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", closeTagPicker);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", closeTagPicker);
|
||||
};
|
||||
}, [tagPickerPos, closeTagPicker]);
|
||||
// The app's menu, under the button, with the picker in it.
|
||||
const tagMenu = useContextMenu<null>();
|
||||
const tagPickerOpen = tagMenu.contextMenu.isOpen;
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
@@ -205,22 +173,8 @@ export function EmailHoverActions({
|
||||
// Previously this cleared every tag on the message, which is a no-op on
|
||||
// an untagged one - hence "clicking it does nothing". Open the picker.
|
||||
if (!onSetTag) break;
|
||||
tagButtonRef.current = e.currentTarget as HTMLElement;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setTagPickerPos((open) =>
|
||||
open
|
||||
? null
|
||||
: {
|
||||
top:
|
||||
rect.bottom + TAG_PICKER_MAX_HEIGHT > window.innerHeight
|
||||
? Math.max(8, rect.top - TAG_PICKER_MAX_HEIGHT - 4)
|
||||
: rect.bottom + 4,
|
||||
left: Math.max(
|
||||
8,
|
||||
Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8),
|
||||
),
|
||||
},
|
||||
);
|
||||
if (tagPickerOpen) tagMenu.closeContextMenu();
|
||||
else tagMenu.openContextMenuFrom(e.currentTarget as HTMLElement, null, "start");
|
||||
break;
|
||||
}
|
||||
case "spam":
|
||||
@@ -264,7 +218,7 @@ export function EmailHoverActions({
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-haspopup={actionId === "tag" ? "menu" : undefined}
|
||||
aria-expanded={actionId === "tag" ? tagPickerPos !== null : undefined}
|
||||
aria-expanded={actionId === "tag" ? tagPickerOpen : undefined}
|
||||
className={cn(
|
||||
"transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||
// Gmail's are round 40px targets that darken rather than change hue.
|
||||
@@ -297,34 +251,32 @@ export function EmailHoverActions({
|
||||
);
|
||||
}
|
||||
|
||||
const tagPicker =
|
||||
tagPickerPos && onSetTag
|
||||
? createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
data-testid="hover-tag-picker"
|
||||
role="menu"
|
||||
className="fixed z-50 w-56 max-w-[18rem] rounded-lg border border-border bg-popover py-1 shadow-lg"
|
||||
style={{ top: tagPickerPos.top, left: tagPickerPos.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
scope={tagScope}
|
||||
onToggle={(tagId) => onSetTag(tagId)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
const tagPicker = onSetTag ? (
|
||||
<ContextMenu
|
||||
ref={tagMenu.menuRef}
|
||||
isOpen={tagPickerOpen}
|
||||
position={tagMenu.contextMenu.position}
|
||||
onClose={tagMenu.closeContextMenu}
|
||||
label={t("tag")}
|
||||
>
|
||||
{/* The menu is rendered in a portal, but React still bubbles its clicks to the row. */}
|
||||
<div
|
||||
data-testid="hover-tag-picker"
|
||||
className="w-56 max-w-[18rem]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TagPicker selectedIds={currentTagIds} scope={tagScope} onToggle={(tagId) => onSetTag(tagId)} />
|
||||
</div>
|
||||
</ContextMenu>
|
||||
) : null;
|
||||
|
||||
// While the picker is open the row is no longer hovered, so the toolbar has to
|
||||
// stay mounted and visible or the picker would close under its own anchor.
|
||||
// Shown on keyboard focus too (R7), not only on hover. Only a control inside
|
||||
// the row counts, and only from the keyboard: a click focuses the row itself
|
||||
// (or the button clicked), and focus-within kept the toolbar on the open row.
|
||||
const visibilityClass = tagPickerPos ? "flex" : "hidden group-hover:flex group-has-[:focus-visible]:flex";
|
||||
const visibilityClass = tagPickerOpen ? "flex" : "hidden group-hover:flex group-has-[:focus-visible]:flex";
|
||||
|
||||
if (hoverActionsMode === 'floating') {
|
||||
return (
|
||||
|
||||
@@ -11,11 +11,15 @@ import type { LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { listRowShowsChips } from "./attachment-chips";
|
||||
import { listVerificationCode } from "@/lib/verification-code";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { SelectionView } from "./selection-view";
|
||||
import { restoreTargetForAll } from "@/lib/trash-origin";
|
||||
import { useSelectionActionList } from "./message-actions";
|
||||
import { useMessageActionShortcut } from "./message-action-shortcuts";
|
||||
import { ViewSourceDialog } from "./view-source-dialog";
|
||||
import { useCopyToAccount, useEmailExport, requestPrint } from "@/hooks/use-message-file-actions";
|
||||
import { BatchTagButton } from "./batch-tag-button";
|
||||
import { cn, cleanPreview } from "@/lib/utils";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput, CheckSquare } from "@/components/icons";
|
||||
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput } from "@/components/icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
|
||||
@@ -51,7 +55,7 @@ import { useTranslations } from "next-intl";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
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 { markSelectionReadWithUndo, runBatchEmailAction, setSelectionStarredWithUndo, 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";
|
||||
@@ -208,6 +212,7 @@ export function EmailList({
|
||||
const openInWindow = useOpenEmailInWindow();
|
||||
const tSpam = useTranslations('email_viewer.spam');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tSelection = useTranslations('selectionView');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const tListExtra = useTranslations('mail_list_extra');
|
||||
@@ -407,6 +412,11 @@ export function EmailList({
|
||||
const [sourceEmail, setSourceEmail] = useState<Email | null>(null);
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
// The selection view's More actions opens only that part of the selection's menu.
|
||||
const [menuSection, setMenuSection] = useState<'all' | 'more'>('all');
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) setMenuSection('all');
|
||||
}, [contextMenu.isOpen]);
|
||||
const later = useTimeoutWhileMounted();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const batchToolbarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -654,6 +664,20 @@ export function EmailList({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchSetStarred = async (starred: boolean) => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await setSelectionStarredWithUndo(client, starred, {
|
||||
success: tSelection(starred ? 'starred' : 'unstarred'),
|
||||
error: tNotifications('error_updating'),
|
||||
labels: undo,
|
||||
});
|
||||
} finally {
|
||||
later(() => setIsProcessing(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUndoSpam = async () => {
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
@@ -777,33 +801,45 @@ export function EmailList({
|
||||
// One read/unread button: "Mark as read" while any selected message is unread.
|
||||
const selectionHasUnread = selectedEmailsForRemoval.some((email) => !email.keywords?.$seen);
|
||||
|
||||
// Archive, Delete, Move, Mark read/unread and Tag, on the bar and in the
|
||||
// reading pane. The pane's copies get their own test ids.
|
||||
const selectionButtons = (where: 'bar' | 'pane') => {
|
||||
const testId = (id: string) => (where === 'bar' ? id : `pane-${id}`);
|
||||
// The pane has room: its buttons say what they do (mail-p2-15). The
|
||||
// bar beside the list stays icons with tooltips.
|
||||
const labelled = where === 'pane';
|
||||
const shape = labelled ? { size: 'sm' as const, title: '', className: 'gap-1.5' } : {};
|
||||
const name = (label: string) => (labelled ? <span data-selection-label="">{label}</span> : null);
|
||||
return (
|
||||
// The selection view's buttons: the selection menu's top level (useSelectionActionList).
|
||||
const shortcutOf = useMessageActionShortcut();
|
||||
const selectionUnread = selectedEmailsForRemoval.filter((email) => !email.keywords?.$seen).length;
|
||||
const selectionRestoreTo = effectiveMailboxRole === 'trash' && hasSelection
|
||||
? restoreTargetForAll(selectedEmailsForRemoval, mailboxes, selectedMailbox)
|
||||
: undefined;
|
||||
const selectionActions = useSelectionActionList({
|
||||
emails: selectedEmailsForRemoval,
|
||||
currentMailboxRole: effectiveMailboxRole,
|
||||
canMove: !moveMenuIsEmpty(selectionMoveModel),
|
||||
canRemoveFromFolder: selectionCanLeaveFolder,
|
||||
canTag: !isScheduledView,
|
||||
onRestore: selectionRestoreTo ? () => void handleBatchMoveToMailbox(selectionRestoreTo) : undefined,
|
||||
onArchive: () => void handleBatchArchive(),
|
||||
onDelete: () => void handleBatchDelete(),
|
||||
onMarkAsRead: (read) => void handleBatchMarkAsRead(read),
|
||||
onSetStarred: (starred) => void handleBatchSetStarred(starred),
|
||||
onMarkAsSpam: () => void handleBatchMarkAsSpam(),
|
||||
onUndoSpam: () => void handleBatchUndoSpam(),
|
||||
onRemoveFromFolder: () => void handleBatchRemoveFromFolder(),
|
||||
});
|
||||
|
||||
// Archive, Delete, Move, Mark read/unread and Tag, as icons on the bar beside the
|
||||
// list. The reading pane has the selection view.
|
||||
const selectionButtons = () => (
|
||||
<>
|
||||
{archiveActionEnabled && effectiveMailboxRole !== 'archive' && (
|
||||
<IconButton label={tContextMenu('archive')} onClick={() => void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')} {...shape}>
|
||||
<IconButton label={tContextMenu('archive')} onClick={() => void handleBatchArchive()} disabled={isProcessing} data-testid="batch-archive">
|
||||
<Archive />
|
||||
{name(tContextMenu('archive'))}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t('batch_actions.delete')}
|
||||
onClick={handleBatchDelete}
|
||||
disabled={isProcessing}
|
||||
data-testid={testId('batch-delete')}
|
||||
{...shape}
|
||||
className={cn("text-destructive hover:text-destructive hover:bg-destructive/10", labelled && "gap-1.5")}
|
||||
data-testid="batch-delete"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 />
|
||||
{name(t('batch_actions.delete'))}
|
||||
</IconButton>
|
||||
{!moveMenuIsEmpty(selectionMoveModel) && (
|
||||
<IconButton
|
||||
@@ -812,25 +848,20 @@ export function EmailList({
|
||||
aria-expanded={moveMenu.contextMenu.isOpen}
|
||||
onClick={(e) => moveMenu.openContextMenuFrom(e.currentTarget, null, 'end')}
|
||||
disabled={isProcessing}
|
||||
data-testid={testId('batch-move')}
|
||||
{...shape}
|
||||
data-testid="batch-move"
|
||||
>
|
||||
<FolderInput />
|
||||
{name(tContextMenu('move_to'))}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread')}
|
||||
onClick={() => handleBatchMarkAsRead(selectionHasUnread)}
|
||||
disabled={isProcessing}
|
||||
data-testid={testId('batch-mark-read')}
|
||||
{...shape}
|
||||
data-testid="batch-mark-read"
|
||||
>
|
||||
{selectionHasUnread ? <MailOpen /> : <Mail />}
|
||||
{name(selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread'))}
|
||||
</IconButton>
|
||||
<BatchTagButton
|
||||
showLabel={labelled}
|
||||
key={hasSelection ? 'selection' : 'none'}
|
||||
title={tContextMenu('tag')}
|
||||
selectedIds={selectionTags.all}
|
||||
@@ -839,11 +870,11 @@ export function EmailList({
|
||||
onToggle={handleBatchToggleTag}
|
||||
active={hasSelection && !isScheduledView}
|
||||
disabled={isProcessing}
|
||||
testId={testId('batch-tag')}
|
||||
testId="batch-tag"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
// What the bar and the pane call the selection: conversations, as the list
|
||||
// shows them, or emails when conversations are off.
|
||||
const selectionTitle = disableThreading
|
||||
@@ -1195,7 +1226,7 @@ export function EmailList({
|
||||
{/* Archive, Delete, Move, Mark read/unread, Tag, then what
|
||||
fits a wide bar. The rest is in its ⋮, the selection's
|
||||
context menu (R6). */}
|
||||
{selectionButtons('bar')}
|
||||
{selectionButtons()}
|
||||
{selectionCanLeaveFolder && !batchNarrow && (
|
||||
<IconButton
|
||||
label={tContextMenu('remove_from_folder')}
|
||||
@@ -1509,32 +1540,24 @@ export function EmailList({
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{/* The reading pane for a selection: its count and the bar's buttons (mail-10). */}
|
||||
{/* The reading pane for a selection: the selection menu's actions as buttons. */}
|
||||
{selectionPaneHost && hasSelection && !isScheduledView && createPortal(
|
||||
<section aria-label={selectionTitle} className="text-center" data-testid="selection-pane">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<CheckSquare className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">{selectionTitle}</h2>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{selectionButtons('pane')}
|
||||
<IconButton
|
||||
label={t('batch_actions.more')}
|
||||
aria-haspopup="menu"
|
||||
data-testid="pane-batch-more"
|
||||
disabled={isProcessing || selectedEmailsForRemoval.length === 0}
|
||||
onClick={(e) => {
|
||||
const anchor = selectedEmailsForRemoval[0];
|
||||
if (anchor) openContextMenuFrom(e.currentTarget, anchor, 'end');
|
||||
}}
|
||||
>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={clearSelection} disabled={isProcessing} className="mt-4 text-muted-foreground hover:text-foreground">
|
||||
{t('batch_actions.clear_selection')}
|
||||
</Button>
|
||||
</section>,
|
||||
<SelectionView
|
||||
title={selectionTitle}
|
||||
unread={selectionUnread}
|
||||
entries={selectionActions.top}
|
||||
shortcutOf={(entry) => (entry.key === 'star' ? undefined : shortcutOf(entry.key, entry.testId === 'ctx-mark-read'))}
|
||||
onMove={(button) => moveMenu.openContextMenuFrom(button, null, 'start')}
|
||||
onMore={(button) => {
|
||||
const anchor = selectedEmailsForRemoval[0];
|
||||
if (!anchor) return;
|
||||
setMenuSection('more');
|
||||
openContextMenuFrom(button, anchor, 'start');
|
||||
}}
|
||||
onClear={clearSelection}
|
||||
tags={{ selectedIds: selectionTags.all, partialIds: selectionTags.some, scope: selectionTags.scope, onToggle: handleBatchToggleTag }}
|
||||
disabled={isProcessing || selectedEmailsForRemoval.length === 0}
|
||||
/>,
|
||||
selectionPaneHost,
|
||||
)}
|
||||
|
||||
@@ -1600,6 +1623,8 @@ export function EmailList({
|
||||
} : undefined}
|
||||
onViewSource={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => setSourceEmail(contextMenuEmail)}
|
||||
onBatchMarkAsRead={(read) => markSelectionRead(read)}
|
||||
onBatchSetStarred={(starred) => void handleBatchSetStarred(starred)}
|
||||
section={menuSection}
|
||||
batchTagIds={selectionTags.all}
|
||||
batchPartialTagIds={selectionTags.some}
|
||||
batchTagScope={selectionTags.scope}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUpDown, Check, CheckCheck, FolderX, MailOpen, MoreVertical, RefreshCw } from "@/components/icons";
|
||||
import { ArrowUpDown, CheckCheck, FolderX, MailOpen, MoreVertical, RefreshCw } from "@/components/icons";
|
||||
import { ToolbarButton } from "@/components/ui/toolbar";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu, ContextMenuCheckItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -106,21 +106,7 @@ export function MailSortSubMenu({ folderRole, onPicked }: { folderRole: string |
|
||||
|
||||
/** A checkable entry of the Sort menu, as in Files'. */
|
||||
function SortItem({ label, checked, onClick, testId }: { label: string; checked: boolean; onClick: () => void; testId?: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
className="w-full px-3 py-1.5 max-md:min-h-11 text-sm text-start flex items-center gap-2 hover:bg-muted focus:outline-none focus-visible:bg-muted"
|
||||
>
|
||||
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
|
||||
{checked && <Check className="w-4 h-4" />}
|
||||
</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
);
|
||||
return <ContextMenuCheckItem kind="radio" label={label} checked={checked} onClick={onClick} testId={testId} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { MessageActionKey } from "./message-actions";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
|
||||
/**
|
||||
* The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts),
|
||||
@@ -32,8 +33,14 @@ export function messageActionShortcut(
|
||||
return entry.key;
|
||||
}
|
||||
|
||||
/** `messageActionShortcut` with the user's single-key setting. */
|
||||
/**
|
||||
* `messageActionShortcut` with the user's single-key setting, in the app's language as
|
||||
* Windows writes the keys ("Umschalt + I" in German), for menus and tooltips.
|
||||
*/
|
||||
export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined {
|
||||
const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false);
|
||||
return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]);
|
||||
return useCallback((key, unread) => {
|
||||
const hint = messageActionShortcut(key, { unread, singleKeys });
|
||||
return hint && formatShortcutCombo(hint);
|
||||
}, [singleKeys]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Mail,
|
||||
MailOpen,
|
||||
Star,
|
||||
StarOff,
|
||||
Pin,
|
||||
PinOff,
|
||||
Trash2,
|
||||
@@ -320,3 +321,155 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
|
||||
if (o.extras && o.extras.length > 0) list = [...list, SEPARATOR, ...o.extras];
|
||||
return tidySeparators(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a message's right-click and long-press menu shows at its top level, in the
|
||||
* browser and in the desktop app alike: answer, file away, mark, spam, open. The rest
|
||||
* goes under "More actions" (rowMenuSections). Restore in Trash, Not spam in Spam and a
|
||||
* draft's or a scheduled message's own entries stay at the top.
|
||||
*/
|
||||
export const ROW_MENU_KEYS: ReadonlySet<MessageActionKey> = new Set<MessageActionKey>([
|
||||
"reschedule", "cancel_and_edit", "cancel_send", "edit_draft",
|
||||
"reply", "reply_all", "forward",
|
||||
"restore", "archive", "delete", "move",
|
||||
"read", "star", "tag",
|
||||
"spam",
|
||||
"open_window",
|
||||
]);
|
||||
|
||||
/**
|
||||
* "More actions", in groups with a line between: forwarding, links, filing and marks,
|
||||
* then save, print and source. Keys not named here (a host's own entries) go before
|
||||
* the last group.
|
||||
*/
|
||||
const MORE_GROUPS: MessageActionKey[][] = [
|
||||
["forward_as_attachment"],
|
||||
["copy_message_link", "copy_conversation_link"],
|
||||
["copy_to_account", "rules", "remove_from_folder", "pin", "report_phishing", "find_from_sender"],
|
||||
["export", "print", "view_source"],
|
||||
];
|
||||
|
||||
function moreGroupOf(key: MessageActionKey): number {
|
||||
const at = MORE_GROUPS.findIndex((group) => group.includes(key));
|
||||
return at >= 0 ? at : MORE_GROUPS.length - 1.5;
|
||||
}
|
||||
|
||||
/** Splits one message's actions into the row menu's top level and its "More actions". */
|
||||
export function rowMenuSections(list: MessageActionEntry[]): { top: MessageActionEntry[]; more: MessageActionEntry[] } {
|
||||
const top = tidySeparators(list.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key)));
|
||||
const rest = list.filter(
|
||||
(entry): entry is Exclude<MessageActionEntry, { kind: "separator" }> => entry.kind !== "separator" && !ROW_MENU_KEYS.has(entry.key),
|
||||
);
|
||||
// Stable: within a group the list's own order stays.
|
||||
const sorted = rest
|
||||
.map((entry, index) => ({ entry, index, group: moreGroupOf(entry.key) }))
|
||||
.sort((a, b) => a.group - b.group || a.index - b.index);
|
||||
const more: MessageActionEntry[] = [];
|
||||
sorted.forEach(({ entry, group }, i) => {
|
||||
if (i > 0 && sorted[i - 1].group !== group) more.push(SEPARATOR);
|
||||
more.push(entry);
|
||||
});
|
||||
return { top, more };
|
||||
}
|
||||
|
||||
export interface SelectionActionOptions {
|
||||
/** The selected messages. */
|
||||
emails: Email[];
|
||||
currentMailboxRole?: string;
|
||||
canMove?: boolean;
|
||||
canRemoveFromFolder?: boolean;
|
||||
canCopyToAccount?: boolean;
|
||||
/** Offer the Tag submenu. */
|
||||
canTag?: boolean;
|
||||
/** In Trash: puts every selected message back. */
|
||||
onRestore?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onSetStarred?: (starred: boolean) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
onRemoveFromFolder?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions for a selection of several messages. The selection's right-click menu
|
||||
* and the reading pane's selection view both show these, in this order: the same
|
||||
* structure as one message's row menu, without what needs a single message (answering,
|
||||
* its own window, links, saving, printing, its source).
|
||||
*/
|
||||
export function useSelectionActionList(o: SelectionActionOptions): { top: MessageActionEntry[]; more: MessageActionEntry[] } {
|
||||
const t = useTranslations("context_menu");
|
||||
const tPhishing = useTranslations("phishing_report");
|
||||
const tRecover = useTranslations("recover_deleted");
|
||||
const tActions = useTranslations("reader_actions");
|
||||
const archiveEnabled = useArchiveActionEnabled();
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
|
||||
const phishing = usePhishingReport(o.emails, o.currentMailboxRole);
|
||||
const role = o.currentMailboxRole;
|
||||
const inJunk = role === "junk";
|
||||
const deleteForever = bulkDeleteIsPermanent({ role });
|
||||
const someUnread = o.emails.some((e) => !e.keywords?.$seen);
|
||||
const someRead = o.emails.some((e) => !!e.keywords?.$seen);
|
||||
const allStarred = o.emails.length > 0 && o.emails.every((e) => !!e.keywords?.$flagged);
|
||||
|
||||
const item = (
|
||||
key: MessageActionKey,
|
||||
icon: Icon,
|
||||
label: string,
|
||||
run: (() => void) | undefined,
|
||||
extra: { destructive?: boolean; testId?: string } = {},
|
||||
): MessageActionEntry => ({ kind: "item", key, icon, label, run: () => run?.(), disabled: !run, ...extra });
|
||||
|
||||
const restore = role === "trash" && o.onRestore
|
||||
? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR]
|
||||
: [];
|
||||
// In Archive there's nowhere further to archive it to.
|
||||
const archive = archiveEnabled && role !== "archive" ? [item("archive", Archive, t("archive"), o.onArchive, { testId: "ctx-archive" })] : [];
|
||||
const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, {
|
||||
destructive: !mountainView,
|
||||
testId: "ctx-delete",
|
||||
});
|
||||
const move: MessageActionEntry[] = o.canMove ? [{ kind: "submenu", key: "move", icon: FolderInput, label: t("move_to"), testId: "ctx-move-to" }] : [];
|
||||
// A selection of read and unread mail gets both.
|
||||
const read = [
|
||||
...(someUnread ? [item("read", MailOpen, t("mark_read"), () => o.onMarkAsRead?.(true), { testId: "ctx-mark-read" })] : []),
|
||||
...(someRead ? [item("read", Mail, t("mark_unread"), () => o.onMarkAsRead?.(false), { testId: "ctx-mark-unread" })] : []),
|
||||
];
|
||||
// Takes the star off when every one has it, else stars them all.
|
||||
const star = o.onSetStarred
|
||||
? [item("star", allStarred ? StarOff : Star, allStarred ? t("unstar") : t("star"), () => o.onSetStarred?.(!allStarred), {
|
||||
testId: allStarred ? "ctx-unstar" : "ctx-star",
|
||||
})]
|
||||
: [];
|
||||
const tag: MessageActionEntry[] = o.canTag ? [{ kind: "submenu", key: "tag", icon: Tag, label: t("tag"), testId: "ctx-tag" }] : [];
|
||||
const spam = spamApplicableIn(role)
|
||||
? [item("spam", inJunk ? ShieldCheck : ShieldAlert, inJunk ? t("not_spam") : t("mark_as_spam"), inJunk ? o.onUndoSpam : o.onMarkAsSpam, {
|
||||
destructive: !inJunk && !mountainView,
|
||||
testId: inJunk ? "ctx-not-spam" : "ctx-spam",
|
||||
})]
|
||||
: [];
|
||||
|
||||
// The row menu's order in each layout (useMessageActionList).
|
||||
const top = tidySeparators(mountainView
|
||||
? [...restore, ...archive, del, ...read, SEPARATOR, ...move, ...tag, SEPARATOR, ...star, ...spam]
|
||||
: [...restore, ...archive, del, SEPARATOR, ...move, SEPARATOR, ...read, ...star, ...tag, SEPARATOR, ...spam]);
|
||||
|
||||
const copy: MessageActionEntry[] = o.canCopyToAccount
|
||||
? [{ kind: "submenu", key: "copy_to_account", icon: Copy, label: t("copy_to_account"), testId: "ctx-copy-to" }]
|
||||
: [];
|
||||
const more = tidySeparators([
|
||||
item("forward_as_attachment", Paperclip, t("forward_as_attachments"), o.onForwardAsAttachment, { testId: "ctx-batch-forward-as-attachment" }),
|
||||
SEPARATOR,
|
||||
...copy,
|
||||
{ kind: "submenu", key: "rules", icon: Filter, label: t("rules.title"), testId: "ctx-rules" },
|
||||
...(o.canRemoveFromFolder && o.onRemoveFromFolder
|
||||
? [item("remove_from_folder", FolderX, t("remove_from_folder"), o.onRemoveFromFolder, { testId: "ctx-remove-from-folder" })]
|
||||
: []),
|
||||
...(phishing.available
|
||||
? [SEPARATOR, item("report_phishing", Fish, tPhishing("action"), phishing.report, { testId: "ctx-batch-report-phishing" })]
|
||||
: []),
|
||||
]);
|
||||
return { top, more };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ interface MoveFolderListProps {
|
||||
* `panel`: the phone's full-width panel (44px rows).
|
||||
*/
|
||||
variant?: "menu" | "dialog" | "panel";
|
||||
/** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */
|
||||
/** Opens the Move dialog, which has the filter. Offered at the end of a long list. */
|
||||
onChooseFolder?: () => void;
|
||||
/** Always show the filter field (the dialog). */
|
||||
alwaysFilter?: boolean;
|
||||
@@ -80,9 +80,8 @@ interface MoveFolderListProps {
|
||||
* greyed, other reachable accounts under their names, and a filter field
|
||||
* once the list is long.
|
||||
*
|
||||
* Each folder with subfolders is wrapped in `data-native-submenu`: the
|
||||
* desktop app's native copy of the menu makes it a submenu there, the folder
|
||||
* itself first (lib/desktop/native-menu.ts).
|
||||
* Each folder with subfolders is wrapped in `data-native-submenu`, which keeps
|
||||
* the folder and its subfolders together as one group.
|
||||
*/
|
||||
export function MoveFolderList({
|
||||
model,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Fish,
|
||||
Plus,
|
||||
} from "@/components/icons";
|
||||
import { spamApplicableIn } from "@/lib/spam-applicable";
|
||||
import { usePhishingReport } from "@/hooks/use-phishing-report";
|
||||
@@ -546,6 +547,7 @@ export function MvListToolbar({
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Plus}
|
||||
label={tSidebar("create_label")}
|
||||
onClick={() => { closeMove(); onCreateFolder(); }}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
|
||||
import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip, Eye, Pencil } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { nativeMenusAvailable } from "@/lib/desktop/native-menu";
|
||||
import { isDesktopApp } from "@/lib/desktop/bridge";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { getAttachmentDisplayName } from "@/lib/attachment-name";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -129,7 +129,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
const tEdit = useTranslations("desktopEdit");
|
||||
const menu = useContextMenu<T>();
|
||||
const [desktopMenu, setDesktopMenu] = useState(false);
|
||||
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
|
||||
useEffect(() => setDesktopMenu(isDesktopApp()), []);
|
||||
const menuTarget = menu.contextMenu.data;
|
||||
// Opens nothing: no preview, and downloads are off.
|
||||
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
|
||||
@@ -355,16 +355,16 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
{desktopMenu && menuTarget && (
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
|
||||
{!locked(menuTarget) && (
|
||||
<ContextMenuItem label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
|
||||
<ContextMenuItem icon={Eye} label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && (
|
||||
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
|
||||
<ContextMenuItem icon={Download} label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && onEdit && canEdit?.(menuTarget) && (
|
||||
<ContextMenuItem label={tEdit("edit")} onClick={() => { menu.closeContextMenu(); onEdit(menuTarget); }} />
|
||||
<ContextMenuItem icon={Pencil} label={tEdit("edit")} onClick={() => { menu.closeContextMenu(); onEdit(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && onCopyFile && (
|
||||
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
|
||||
<ContextMenuItem icon={Copy} label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { CONTEXT_MENU_ITEM_CLASS, menuLevelItems } from "@/components/ui/context-menu";
|
||||
import { attachmentIcon } from "@/components/email/attachment-chips";
|
||||
import { listRecentFiles, recentWhen, shortFolder } from "@/lib/desktop/recent-files";
|
||||
import type { DesktopRecentFile } from "@/lib/desktop/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Only an icon the shell made: a PNG as a data: URL. */
|
||||
const ICON_URL = /^data:image\/png;base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
/**
|
||||
* The top of the composer's Attach menu in the desktop app: the files the OS says were
|
||||
* used last, as Outlook lists them. Mounted when the menu opens, from that click, which is
|
||||
* what the shell asks for. Until the list is there, and when it's empty, it takes no room.
|
||||
* It stays an HTML menu (data-native-menu): a native one can't show the icons and folders.
|
||||
*/
|
||||
export function RecentFilesMenuSection({ onPick }: { onPick: (file: DesktopRecentFile) => void }) {
|
||||
const t = useTranslations("desktopAttachRecent");
|
||||
const locale = useLocale();
|
||||
const [files, setFiles] = useState<DesktopRecentFile[] | null>(null);
|
||||
// One request per opening, also when a strict-mode remount runs the effect twice.
|
||||
const request = useRef<Promise<DesktopRecentFile[]> | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
request.current ??= listRecentFiles();
|
||||
void request.current.then((list) => {
|
||||
if (!cancelled) setFiles(list);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The menu put the focus on its first entry before the list came. If it's still
|
||||
// there, the first file takes it: the list is what comes first.
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const menu = root?.closest<HTMLElement>('[role="menu"]');
|
||||
if (!root || !menu || !files?.length) return;
|
||||
const active = document.activeElement;
|
||||
const others = menuLevelItems(menu).filter((item) => !root.contains(item));
|
||||
if (active && active === others[0]) root.querySelector<HTMLElement>('[role="menuitem"]')?.focus({ preventScroll: true });
|
||||
}, [files]);
|
||||
|
||||
const now = Date.now();
|
||||
return (
|
||||
<div ref={rootRef} data-native-menu="html" data-testid="composer-recent-files" hidden={!files?.length}>
|
||||
{files && files.length > 0 && (
|
||||
<>
|
||||
<div role="presentation" className="px-3 pb-1 pt-1.5 text-xs font-medium text-muted-foreground">
|
||||
{t("heading")}
|
||||
</div>
|
||||
{files.map((file) => {
|
||||
const Fallback = attachmentIcon(null, file.name).icon;
|
||||
return (
|
||||
<button
|
||||
key={file.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title={file.path}
|
||||
onClick={() => onPick(file)}
|
||||
data-testid="composer-recent-file"
|
||||
className={cn(CONTEXT_MENU_ITEM_CLASS, "w-[min(24rem,calc(100vw-3rem))] gap-2.5")}
|
||||
>
|
||||
{file.icon && ICON_URL.test(file.icon) ? (
|
||||
<img src={file.icon} alt="" aria-hidden="true" draggable={false} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Fallback className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{file.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{shortFolder(file.folder)}</span>
|
||||
</span>
|
||||
<span className="shrink-0 self-start pt-0.5 text-xs text-muted-foreground">{recentWhen(file.usedAt, now, locale)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div role="separator" className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, MoreHorizontal, CheckSquare } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, MenuShortcut } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import type { MessageActionEntry, MessageActionKey } from "./message-actions";
|
||||
import type { TagScope } from "@/hooks/use-account-tags";
|
||||
|
||||
/** The narrowest a button gets, whatever its words (CSS px). */
|
||||
const MIN_BUTTON_WIDTH = 120;
|
||||
const GAP = 8;
|
||||
|
||||
/**
|
||||
* How many buttons go on each line: as few lines as the width allows, and the lines as
|
||||
* even as they can be, so no button is left alone at the end of a ragged wrap. With room
|
||||
* for two a line and an odd count, the last one (More actions) gets a line of its own
|
||||
* across the full width; with room for one, it's a column.
|
||||
*/
|
||||
export function selectionRows(count: number, perLine: number): number[] {
|
||||
if (count <= 0) return [];
|
||||
const most = Math.max(1, Math.min(perLine, count));
|
||||
if (most === 1) return Array.from({ length: count }, () => 1);
|
||||
const lines = Math.ceil(count / most);
|
||||
const base = Math.floor(count / lines);
|
||||
if (lines > 1 && base < 2) return [...Array.from({ length: Math.floor(count / 2) }, () => 2), 1];
|
||||
const longer = count % lines;
|
||||
return Array.from({ length: lines }, (_, i) => base + (i < longer ? 1 : 0));
|
||||
}
|
||||
|
||||
/** "pane-batch-archive": the ids the selection bar's buttons had, for the pane's copies. */
|
||||
function paneTestId(entry: { key: string; testId?: string }): string {
|
||||
const id = (entry.testId ?? entry.key).replace(/^ctx-/, "").replace(/^move-to$/, "move");
|
||||
return `pane-batch-${id}`;
|
||||
}
|
||||
|
||||
export interface SelectionViewProps {
|
||||
/** "4 conversations selected". */
|
||||
title: string;
|
||||
/** How many of them are unread; a line under the title when there are some. */
|
||||
unread: number;
|
||||
/** The selection menu's top level (useSelectionActionList), in its order. */
|
||||
entries: MessageActionEntry[];
|
||||
/** The key hint for an entry, as the menu shows it. */
|
||||
shortcutOf: (entry: Extract<MessageActionEntry, { kind: "item" }>) => string | undefined;
|
||||
/** Opens the Move list under the button. */
|
||||
onMove: (button: HTMLElement) => void;
|
||||
/** Opens what the selection menu has under "More actions", under the button. */
|
||||
onMore: (button: HTMLElement) => void;
|
||||
onClear: () => void;
|
||||
/** The tag picker for the whole selection. */
|
||||
tags: { selectedIds: string[]; partialIds: string[]; scope: TagScope | null; onToggle: (tagId: string) => void };
|
||||
disabled?: boolean;
|
||||
/** Entries the device can't run now (offline). */
|
||||
isDisabled?: (key: MessageActionKey) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reading pane while several messages are selected, as Outlook lays it out: what's
|
||||
* selected, the selection menu's actions as buttons in even lines, More actions for the
|
||||
* rest, and a way out. The buttons and their order come from the same list as the
|
||||
* selection's right-click menu.
|
||||
*/
|
||||
export function SelectionView({ title, unread, entries, shortcutOf, onMove, onMore, onClear, tags, disabled, isDisabled }: SelectionViewProps) {
|
||||
const t = useTranslations("email_list");
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
const tCount = useTranslations("notification_center");
|
||||
const tagMenu = useContextMenu<null>();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [perLine, setPerLine] = useState(4);
|
||||
|
||||
// As many buttons a line as fit the pane (a container's width, not the window's), each
|
||||
// as wide as the longest words of any of them, so none has to cut its label.
|
||||
const labelsKey = entries.map((entry) => (entry.kind === "separator" ? "" : entry.label)).join("|");
|
||||
useLayoutEffect(() => {
|
||||
const grid = gridRef.current;
|
||||
if (!grid) return;
|
||||
const measure = () => {
|
||||
const width = grid.clientWidth;
|
||||
if (width <= 0) return;
|
||||
let need = MIN_BUTTON_WIDTH;
|
||||
for (const button of Array.from(grid.querySelectorAll<HTMLElement>("button"))) {
|
||||
// Its icons, the label's whole text, the gaps between them, padding and border:
|
||||
// what it needs whatever width it has now.
|
||||
const style = getComputedStyle(button);
|
||||
const parts = Array.from(button.children) as HTMLElement[];
|
||||
const content = parts.reduce((sum, part) => sum + (part.hasAttribute("data-selection-label") ? part.scrollWidth : part.getBoundingClientRect().width), 0);
|
||||
const gaps = (parseFloat(style.columnGap) || 0) * Math.max(0, parts.length - 1);
|
||||
const frame = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0);
|
||||
need = Math.max(need, Math.ceil(content + gaps + frame) + 2);
|
||||
}
|
||||
setPerLine(Math.max(1, Math.floor((width + GAP) / (need + GAP))));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(grid);
|
||||
return () => observer.disconnect();
|
||||
}, [labelsKey]);
|
||||
|
||||
// After an action the button that had the focus can go (Mark as read once all are
|
||||
// read): the focus stays in the view, on the button now in its place.
|
||||
const focused = useRef<{ el: HTMLElement; index: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const grid = gridRef.current;
|
||||
const last = focused.current;
|
||||
if (!grid || !last || last.el.isConnected) return;
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body) return;
|
||||
const buttons = Array.from(grid.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"));
|
||||
buttons[Math.min(last.index, buttons.length - 1)]?.focus();
|
||||
});
|
||||
|
||||
const items = entries.filter((entry) => entry.kind !== "separator");
|
||||
// More actions is the last button of the grid.
|
||||
const lines = selectionRows(items.length + 1, perLine);
|
||||
const widest = Math.max(1, ...lines);
|
||||
const cell = `calc((100% - ${(widest - 1) * GAP}px) / ${widest})`;
|
||||
|
||||
const button = (entry: Exclude<MessageActionEntry, { kind: "separator" }>, index: number) => {
|
||||
const Icon = entry.icon;
|
||||
const common = {
|
||||
variant: "outline" as const,
|
||||
size: "sm" as const,
|
||||
style: { width: cell },
|
||||
className: cn(
|
||||
"gap-2 min-w-0 justify-center",
|
||||
entry.kind === "item" && entry.destructive && "hover:bg-destructive/10 hover:text-destructive",
|
||||
),
|
||||
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => { focused.current = { el: e.currentTarget, index }; },
|
||||
};
|
||||
const label = <span className="truncate" data-selection-label="">{entry.label}</span>;
|
||||
if (entry.kind === "submenu") {
|
||||
const open = (el: HTMLElement) => (entry.key === "tag" ? tagMenu.openContextMenuFrom(el, null, "start") : onMove(el));
|
||||
return (
|
||||
<Button
|
||||
key={entry.key}
|
||||
{...common}
|
||||
title={entry.label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={entry.key === "tag" ? tagMenu.contextMenu.isOpen : undefined}
|
||||
data-testid={paneTestId(entry)}
|
||||
disabled={disabled || isDisabled?.(entry.key)}
|
||||
onClick={(e) => open(e.currentTarget)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{label}
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const key = shortcutOf(entry);
|
||||
return (
|
||||
<Button
|
||||
// Star keeps its button when it turns into Unstar, and with it the focus.
|
||||
key={entry.key === "read" ? `read-${entry.testId}` : entry.key}
|
||||
{...common}
|
||||
title={key ? `${entry.label} (${key})` : entry.label}
|
||||
data-testid={paneTestId(entry)}
|
||||
disabled={disabled || entry.disabled || isDisabled?.(entry.key)}
|
||||
onClick={entry.run}
|
||||
>
|
||||
<Icon className={cn("size-4 shrink-0", entry.destructive && "text-destructive")} />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const all = [
|
||||
...items.map((entry, index) => button(entry, index)),
|
||||
<Button
|
||||
key="more"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
style={{ width: cell }}
|
||||
className="gap-2 min-w-0 justify-center"
|
||||
aria-haspopup="menu"
|
||||
data-testid="pane-batch-more"
|
||||
disabled={disabled}
|
||||
onFocus={(e) => { focused.current = { el: e.currentTarget, index: items.length }; }}
|
||||
onClick={(e) => onMore(e.currentTarget)}
|
||||
>
|
||||
<MoreHorizontal className="size-4 shrink-0" />
|
||||
<span className="truncate" data-selection-label="">{tViewer("more_actions")}</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>,
|
||||
];
|
||||
let at = 0;
|
||||
const rows = lines.map((length) => {
|
||||
const row = all.slice(at, at + length);
|
||||
at += length;
|
||||
// A line of one under lines of two spans the width.
|
||||
return length === 1 && widest > 1 ? row.map((el) => cloneElement(el, { style: { width: "100%" } })) : row;
|
||||
});
|
||||
|
||||
return (
|
||||
<section aria-label={title} className="mx-auto w-full max-w-3xl px-4 text-center" data-testid="selection-pane">
|
||||
<CheckSquare className="mx-auto mb-3 size-6 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{unread > 0 && <p className="mt-0.5 text-sm text-muted-foreground" data-testid="selection-unread">{tCount("unread_count", { count: unread })}</p>}
|
||||
<div ref={gridRef} className="mt-5 flex flex-col items-center gap-2" data-testid="selection-actions">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex w-full justify-center gap-2" data-selection-line="">
|
||||
{row}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
disabled={disabled}
|
||||
className="mt-3 gap-2 text-muted-foreground hover:text-foreground"
|
||||
aria-keyshortcuts="Escape"
|
||||
data-testid="selection-clear"
|
||||
>
|
||||
{t("batch_actions.clear_selection")}
|
||||
<MenuShortcut keys="Esc" />
|
||||
</Button>
|
||||
<ContextMenu
|
||||
ref={tagMenu.menuRef}
|
||||
isOpen={tagMenu.contextMenu.isOpen}
|
||||
position={tagMenu.contextMenu.position}
|
||||
onClose={tagMenu.closeContextMenu}
|
||||
label={items.find((entry) => entry.key === "tag")?.label}
|
||||
>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker selectedIds={tags.selectedIds} partialIds={tags.partialIds} onToggle={tags.onToggle} scope={tags.scope} />
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -13,31 +12,26 @@ import {
|
||||
Loader2,
|
||||
} from '@/components/icons';
|
||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
import { ContextMenu, ContextMenuHeader, ContextMenuItem } from '@/components/ui/context-menu';
|
||||
import { useContextMenu } from '@/hooks/use-context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { StructuredCard } from '@/lib/structured-mail';
|
||||
import { useBookingCalendar } from './use-booking-calendar';
|
||||
import { useCollectionNames } from "@/hooks/use-collection-names";
|
||||
|
||||
const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50';
|
||||
const PICKER_WIDTH = 208;
|
||||
const EDGE = 8;
|
||||
|
||||
interface PickerPosition { top: number; left?: number; right?: number; maxHeight: number }
|
||||
|
||||
/** Where the picker goes: under the trigger, flipped above when there's no room, always inside the viewport. */
|
||||
function pickerPosition(rect: DOMRect, count: number): PickerPosition {
|
||||
const width = Math.min(PICKER_WIDTH, window.innerWidth - 2 * EDGE);
|
||||
const wanted = Math.min(36 * count + 40, 320);
|
||||
const below = window.innerHeight - rect.bottom - 4 - EDGE;
|
||||
const above = rect.top - 4 - EDGE;
|
||||
const placeBelow = below >= wanted || below >= above;
|
||||
const maxHeight = Math.max(120, Math.min(wanted, placeBelow ? below : above));
|
||||
const top = placeBelow ? rect.bottom + 4 : Math.max(EDGE, rect.top - 4 - maxHeight);
|
||||
const clamp = (value: number) => Math.max(EDGE, Math.min(value, window.innerWidth - width - EDGE));
|
||||
return isDocumentRTL()
|
||||
? { top, right: clamp(window.innerWidth - rect.right), maxHeight }
|
||||
: { top, left: clamp(rect.left), maxHeight };
|
||||
/** A calendar's colour as a menu row's icon, one component per colour. */
|
||||
const dots = new Map<string, React.ComponentType<{ className?: string }>>();
|
||||
function colorDot(color: string): React.ComponentType<{ className?: string }> {
|
||||
let Dot = dots.get(color);
|
||||
if (!Dot) {
|
||||
Dot = function CalendarDot({ className }: { className?: string }) {
|
||||
return <span className={cn(className, "rounded-full")} style={{ backgroundColor: color }} aria-hidden="true" />;
|
||||
};
|
||||
dots.set(color, Dot);
|
||||
}
|
||||
return Dot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,49 +46,15 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
const t = useTranslations('structured_mail_calendar');
|
||||
const calendar = useBookingCalendar(booking, email);
|
||||
const { status, calendars, busy, error, notice } = calendar;
|
||||
const [picker, setPicker] = useState<PickerPosition | null>(null);
|
||||
const picker = useContextMenu<null>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
// An action was started from here: once it is done, focus goes to its
|
||||
// outcome instead of being lost with the button or picker that vanished.
|
||||
const focusAfterAction = useRef(false);
|
||||
|
||||
const closePicker = useCallback(() => setPicker(null), []);
|
||||
const pickerOpen = picker !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
pickerRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
||||
const onPointer = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (pickerRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
|
||||
closePicker();
|
||||
};
|
||||
// Scrolling the list itself must not close it; scrolling the page moves the trigger away.
|
||||
const onScroll = (event: Event) => {
|
||||
if (event.target instanceof Node && pickerRef.current?.contains(event.target)) return;
|
||||
closePicker();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closePicker();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', closePicker);
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('touchstart', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', closePicker);
|
||||
document.removeEventListener('mousedown', onPointer);
|
||||
document.removeEventListener('touchstart', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [pickerOpen, closePicker]);
|
||||
const closePicker = picker.closeContextMenu;
|
||||
const pickerOpen = picker.contextMenu.isOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (busy || !focusAfterAction.current) return;
|
||||
@@ -107,21 +67,6 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
target?.focus();
|
||||
}, [busy, status, notice, error]);
|
||||
|
||||
const onMenuKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const items = Array.from(pickerRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
|
||||
if (items.length === 0) return;
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | null = null;
|
||||
if (event.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length;
|
||||
else if (event.key === 'ArrowUp') next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length;
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = items.length - 1;
|
||||
else if (event.key === 'Tab') closePicker();
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
items[next].focus();
|
||||
};
|
||||
|
||||
const act = (action: () => Promise<void>) => {
|
||||
focusAfterAction.current = true;
|
||||
void action();
|
||||
@@ -137,11 +82,11 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
act(() => calendar.add(calendars[0]?.id));
|
||||
return;
|
||||
}
|
||||
if (picker) {
|
||||
if (pickerOpen) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
if (triggerRef.current) setPicker(pickerPosition(triggerRef.current.getBoundingClientRect(), calendars.length));
|
||||
if (triggerRef.current) picker.openContextMenuFrom(triggerRef.current, null, 'start');
|
||||
};
|
||||
|
||||
const noticeText = notice === 'added' ? t('added') : notice === 'updated' ? t('updated') : notice === 'removed' ? t('removed') : null;
|
||||
@@ -158,41 +103,34 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
|
||||
onClick={onAdd}
|
||||
disabled={busy}
|
||||
aria-haspopup={calendars.length > 1 ? 'menu' : undefined}
|
||||
aria-expanded={calendars.length > 1 ? Boolean(picker) : undefined}
|
||||
aria-expanded={calendars.length > 1 ? pickerOpen : undefined}
|
||||
className={BUTTON}
|
||||
>
|
||||
<CalendarPlus className="w-3.5 h-3.5" />
|
||||
{t('add_to_calendar')}
|
||||
{calendars.length > 1 && <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
{picker && calendars.length > 1 && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
aria-label={t('select_calendar')}
|
||||
onKeyDown={onMenuKey}
|
||||
className="fixed bg-background rounded-lg shadow-lg border border-border z-50 py-1 overflow-y-auto"
|
||||
style={{ top: picker.top, left: picker.left, right: picker.right, maxHeight: picker.maxHeight, width: `min(${PICKER_WIDTH}px, calc(100vw - ${2 * EDGE}px))` }}
|
||||
{calendars.length > 1 && (
|
||||
<ContextMenu
|
||||
ref={picker.menuRef}
|
||||
isOpen={pickerOpen}
|
||||
position={picker.contextMenu.position}
|
||||
onClose={closePicker}
|
||||
label={t('select_calendar')}
|
||||
>
|
||||
<div role="presentation" className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{t('select_calendar')}</div>
|
||||
<ContextMenuHeader>{t('select_calendar')}</ContextMenuHeader>
|
||||
{calendars.map((cal) => (
|
||||
<button
|
||||
<ContextMenuItem
|
||||
key={cal.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
icon={colorDot(sanitizeColor(cal.color))}
|
||||
label={shownName.calendar(cal)}
|
||||
onClick={() => {
|
||||
closePicker();
|
||||
act(() => calendar.add(cal.id));
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
|
||||
<span className="truncate text-foreground">{shownName.calendar(cal)}</span>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FileBrowser } from '@/components/files/file-browser';
|
||||
import { FileDetailsPanel } from '@/components/files/file-details-panel';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { useFilesSyncStore } from '@/stores/files-sync-store';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import type { FilesSyncController, FilesSyncItemStatus } from '@/lib/files-sync/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Files on this computer on the Files screen: "Show in Explorer" (Finder, file
|
||||
// manager) for what a root covers, and the sync status on rows and in the
|
||||
// details panel. The next-intl mock returns keys.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
|
||||
const NOTES = {
|
||||
id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain',
|
||||
contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
|
||||
} as FileResource;
|
||||
const PLANS = {
|
||||
id: 'd-plans', name: 'Plans', serverName: 'Plans', isDirectory: true, contentType: '',
|
||||
contentLength: 0, lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
|
||||
} as unknown as FileResource;
|
||||
|
||||
function controller(covered: string[]) {
|
||||
return {
|
||||
coverage: vi.fn((id: string) => (covered.includes(id) ? { rootId: 'r1', kind: 'folder' as const, path: id } : null)),
|
||||
showInFolder: vi.fn(async () => true),
|
||||
} as unknown as FilesSyncController & { coverage: ReturnType<typeof vi.fn>; showInFolder: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function setPlatform(platform: 'win32' | 'darwin' | 'linux') {
|
||||
window.bulwarkDesktop = { version: 1, platform } as unknown as BulwarkDesktopBridge;
|
||||
}
|
||||
|
||||
function renderBrowser(resources: FileResource[] = [NOTES, PLANS]) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={resources}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onPreviewImage={vi.fn()}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
onNavigate={vi.fn()}
|
||||
onSetSelection={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onPreviewFile={vi.fn()}
|
||||
onDownload={noop}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function moreButton(name: string) {
|
||||
return screen.getAllByRole('button', { name: 'more_actions' }).find((b) => b.closest('[data-resource]')?.getAttribute('data-resource') === name)!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
useFilesSyncStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('Show in Explorer on the Files screen', () => {
|
||||
it('is in the row menu of a covered file and folder only, and opens it there', () => {
|
||||
setPlatform('win32');
|
||||
const c = controller(['f-notes', 'd-plans']);
|
||||
useFilesSyncStore.setState({ available: true, controller: c });
|
||||
renderBrowser();
|
||||
fireEvent.click(moreButton('notes.txt'));
|
||||
const item = within(screen.getByRole('menu')).getByRole('menuitem', { name: 'show_in.explorer' });
|
||||
fireEvent.click(item);
|
||||
expect(c.showInFolder).toHaveBeenCalledWith('f-notes');
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
fireEvent.click(moreButton('Plans'));
|
||||
fireEvent.click(within(screen.getByRole('menu')).getByRole('menuitem', { name: 'show_in.explorer' }));
|
||||
expect(c.showInFolder).toHaveBeenLastCalledWith('d-plans');
|
||||
});
|
||||
|
||||
it('is not offered for what no root covers, nor outside the app', () => {
|
||||
setPlatform('win32');
|
||||
useFilesSyncStore.setState({ available: true, controller: controller(['d-plans']) });
|
||||
renderBrowser();
|
||||
fireEvent.click(moreButton('notes.txt'));
|
||||
expect(within(screen.getByRole('menu')).queryByRole('menuitem', { name: 'show_in.explorer' })).toBeNull();
|
||||
cleanup();
|
||||
useFilesSyncStore.setState({ available: false, controller: controller(['f-notes']) });
|
||||
renderBrowser();
|
||||
fireEvent.click(moreButton('notes.txt'));
|
||||
expect(within(screen.getByRole('menu')).queryByRole('menuitem', { name: /show_in/ })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['darwin', 'show_in.finder'],
|
||||
['linux', 'show_in.file_manager'],
|
||||
] as const)('is named for the platform: %s', (platform, label) => {
|
||||
setPlatform(platform);
|
||||
useFilesSyncStore.setState({ available: true, controller: controller(['f-notes']) });
|
||||
renderBrowser([NOTES]);
|
||||
fireEvent.click(moreButton('notes.txt'));
|
||||
expect(within(screen.getByRole('menu')).getByRole('menuitem', { name: label })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync status on the Files screen', () => {
|
||||
it.each<FilesSyncItemStatus>(['synced', 'syncing', 'pending', 'error', 'online-only'])('names a %s row', (state) => {
|
||||
useFilesSyncStore.setState({ available: true, controller: controller([]), items: { 'f-notes': state } });
|
||||
renderBrowser([NOTES, PLANS]);
|
||||
const row = document.querySelector('[data-resource="notes.txt"]') as HTMLElement;
|
||||
expect(within(row).getByRole('img', { name: `item.${state}` })).toBeInTheDocument();
|
||||
// Plans has no status: no badge.
|
||||
const plans = document.querySelector('[data-resource="Plans"]') as HTMLElement;
|
||||
expect(plans.querySelector('[data-files-sync-item]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows nothing outside the app', () => {
|
||||
useFilesSyncStore.setState({ available: false, items: { 'f-notes': 'synced' } });
|
||||
renderBrowser([NOTES]);
|
||||
expect(document.querySelector('[data-files-sync-item]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('details panel', () => {
|
||||
function renderPanel() {
|
||||
render(
|
||||
<FileDetailsPanel
|
||||
resource={NOTES}
|
||||
selection={[NOTES]}
|
||||
folderName="My files"
|
||||
folderItems={[NOTES]}
|
||||
path="/notes.txt"
|
||||
isStarred={false}
|
||||
canShare={false}
|
||||
canPreview={false}
|
||||
canEdit={false}
|
||||
iconOptions={{ showIcons: true, coloredIcons: true, showThumbnails: false }}
|
||||
getImageUrl={async () => ''}
|
||||
onClose={vi.fn()}
|
||||
onOpen={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onShare={vi.fn()}
|
||||
onToggleStar={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('says the status and offers Show in Finder for a covered file', () => {
|
||||
setPlatform('darwin');
|
||||
const c = controller(['f-notes']);
|
||||
useFilesSyncStore.setState({ available: true, controller: c, items: { 'f-notes': 'pending' } });
|
||||
renderPanel();
|
||||
expect(screen.getByText('item.label')).toBeInTheDocument();
|
||||
expect(screen.getByText('item.pending')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'show_in.finder' }));
|
||||
expect(c.showInFolder).toHaveBeenCalledWith('f-notes');
|
||||
});
|
||||
|
||||
it('has neither for a file no root covers', () => {
|
||||
setPlatform('win32');
|
||||
useFilesSyncStore.setState({ available: true, controller: controller([]) });
|
||||
renderPanel();
|
||||
expect(screen.queryByText('item.label')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /show_in/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import en from '@/locales/en/common.json';
|
||||
import type { FilesRoot } from '@/lib/desktop/types';
|
||||
import type { FilesSyncController } from '@/lib/files-sync/types';
|
||||
|
||||
// The mass-delete question asked wherever the user is (FilesSyncPrompts, mounted
|
||||
// with the app-wide hosts), and kept away while the settings page asks it itself.
|
||||
|
||||
vi.unmock('next-intl');
|
||||
|
||||
const { useFilesSyncStore } = await import('@/stores/files-sync-store');
|
||||
const { FilesSyncPrompts, useInlineMassDeleteQuestions } = await import('../files-sync-prompts');
|
||||
|
||||
const ROOT: FilesRoot = {
|
||||
id: 'r1', kind: 'folder', account: 'acc', path: 'C:\\Users\\ann\\Projects', remoteId: 'n1', remoteLabel: 'Work / Projects', paused: false, createdAt: 1,
|
||||
};
|
||||
|
||||
function setup(count = 80, side: 'local' | 'remote' = 'local') {
|
||||
const controller = { confirmMassDelete: vi.fn(async () => {}) } as unknown as FilesSyncController & { confirmMassDelete: ReturnType<typeof vi.fn> };
|
||||
useFilesSyncStore.setState({
|
||||
available: true,
|
||||
account: 'acc',
|
||||
controller,
|
||||
roots: [ROOT],
|
||||
status: { r1: { rootId: 'r1', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count, side } } },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
const wrap = (node: React.ReactNode) => <NextIntlClientProvider locale="en" messages={en}>{node}</NextIntlClientProvider>;
|
||||
|
||||
afterEach(() => useFilesSyncStore.getState().reset());
|
||||
|
||||
describe('mass-delete prompt, app-wide', () => {
|
||||
it('asks with the count and the side, and deletes on yes', () => {
|
||||
const controller = setup(80, 'local');
|
||||
render(wrap(<FilesSyncPrompts />));
|
||||
const dialog = screen.getByRole('alertdialog', { name: 'Delete 80 items?' });
|
||||
expect(dialog).toHaveTextContent('80 items were deleted from Projects on this computer. Delete them in Files too?');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete them' }));
|
||||
expect(controller.confirmMassDelete).toHaveBeenCalledWith('r1', true);
|
||||
});
|
||||
|
||||
it('brings them back on no, naming the Files folder when they went there', () => {
|
||||
const controller = setup(51, 'remote');
|
||||
render(wrap(<FilesSyncPrompts />));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('51 items were deleted from Work / Projects in Files. Delete them on this computer too?');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Bring them back' }));
|
||||
expect(controller.confirmMassDelete).toHaveBeenCalledWith('r1', false);
|
||||
});
|
||||
|
||||
it('goes away on "Decide later" without answering, and comes back for a new question', () => {
|
||||
const controller = setup(80);
|
||||
render(wrap(<FilesSyncPrompts />));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Decide later' }));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(controller.confirmMassDelete).not.toHaveBeenCalled();
|
||||
act(() => useFilesSyncStore.setState({
|
||||
status: { r1: { rootId: 'r1', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 95, side: 'local' } } },
|
||||
}));
|
||||
expect(screen.getByRole('alertdialog', { name: 'Delete 95 items?' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks nothing when no folder waits for an answer, or outside the app', () => {
|
||||
setup();
|
||||
act(() => useFilesSyncStore.setState({ status: { r1: { rootId: 'r1', state: 'paused', pending: 0, done: 0, lastSyncedAt: null } } }));
|
||||
const { unmount } = render(wrap(<FilesSyncPrompts />));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
unmount();
|
||||
setup();
|
||||
act(() => useFilesSyncStore.setState({ available: false }));
|
||||
render(wrap(<FilesSyncPrompts />));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('stays away while the settings page asks in its rows', () => {
|
||||
setup();
|
||||
function Page() {
|
||||
useInlineMassDeleteQuestions();
|
||||
return null;
|
||||
}
|
||||
const { rerender } = render(wrap(<><FilesSyncPrompts /><Page /></>));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
rerender(wrap(<FilesSyncPrompts />));
|
||||
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, CheckCircle2, Clock, Cloud, FolderOpen, RefreshCw, type AppIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilesSyncItemStatus } from "@/lib/files-sync/types";
|
||||
import { useFilesSyncStore } from "@/stores/files-sync-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { showInKey } from "./files-sync-text";
|
||||
|
||||
/*
|
||||
* Files on this computer, on the Files screen: "Show in Explorer" (Finder, file
|
||||
* manager) on a file or folder a synced folder or the drive covers, and its sync
|
||||
* status on rows and in the details panel. All of it reads stores/files-sync-store.ts
|
||||
* and calls the engine's controller; nothing shows outside the desktop app.
|
||||
*/
|
||||
|
||||
const ITEM_ICONS: Record<FilesSyncItemStatus, { icon: AppIcon; className: string }> = {
|
||||
synced: { icon: CheckCircle2, className: "text-green-600 dark:text-green-400" },
|
||||
syncing: { icon: RefreshCw, className: "text-primary animate-spin motion-reduce:animate-none" },
|
||||
pending: { icon: Clock, className: "text-muted-foreground" },
|
||||
error: { icon: AlertCircle, className: "text-destructive" },
|
||||
"online-only": { icon: Cloud, className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
/** The node's sync status, when the engine has one for it. */
|
||||
function useItemStatus(nodeId: string): FilesSyncItemStatus | null {
|
||||
return useFilesSyncStore((s) => (s.available ? s.items[nodeId] ?? null : null));
|
||||
}
|
||||
|
||||
/** "Show in Explorer" for a node a root covers, or null. */
|
||||
export function useShowInFolder(nodeId: string | null | undefined): { label: string; run: () => void } | null {
|
||||
const t = useTranslations("files_sync");
|
||||
const available = useFilesSyncStore((s) => s.available);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
// Coverage changes with the roots and what the engine knows of the tree.
|
||||
useFilesSyncStore((s) => s.roots);
|
||||
useFilesSyncStore((s) => s.items);
|
||||
if (!available || !controller || !nodeId) return null;
|
||||
if (!controller.coverage(nodeId)) return null;
|
||||
return {
|
||||
label: t(showInKey(getDesktopBridge()?.platform)),
|
||||
run: () => {
|
||||
void controller.showInFolder(nodeId).then(
|
||||
(shown) => { if (!shown) toast.error(t("show_in.failed")); },
|
||||
() => toast.error(t("show_in.failed")),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The menu entry of a row on the Files screen. */
|
||||
export function ShowInFolderMenuItem({ nodeId, onRan }: { nodeId: string; onRan: () => void }) {
|
||||
const showIn = useShowInFolder(nodeId);
|
||||
if (!showIn) return null;
|
||||
return <ContextMenuItem icon={FolderOpen} label={showIn.label} testId="files-sync-show-in" onClick={() => { onRan(); showIn.run(); }} />;
|
||||
}
|
||||
|
||||
/** The same in the details panel's buttons. */
|
||||
export function ShowInFolderButton({ nodeId }: { nodeId: string }) {
|
||||
const showIn = useShowInFolder(nodeId);
|
||||
if (!showIn) return null;
|
||||
return (
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1.5" onClick={showIn.run} data-testid="files-sync-show-in">
|
||||
<FolderOpen className="w-4 h-4" />{showIn.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small icon on a row, named for screen readers and on hover. */
|
||||
export function FilesSyncBadge({ nodeId, className }: { nodeId: string; className?: string }) {
|
||||
const t = useTranslations("files_sync");
|
||||
const status = useItemStatus(nodeId);
|
||||
if (!status) return null;
|
||||
const { icon: Icon, className: iconClass } = ITEM_ICONS[status];
|
||||
const label = t(`item.${status}`);
|
||||
return (
|
||||
<span role="img" aria-label={label} title={label} className="inline-flex shrink-0" data-files-sync-item={status}>
|
||||
<Icon className={cn("w-3.5 h-3.5", iconClass, className)} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** "On this computer: Synced", a row of the details panel's list. */
|
||||
export function FilesSyncDetailsRow({ nodeId }: { nodeId: string }) {
|
||||
const t = useTranslations("files_sync");
|
||||
const status = useItemStatus(nodeId);
|
||||
if (!status) return null;
|
||||
const { icon: Icon, className: iconClass } = ITEM_ICONS[status];
|
||||
return (
|
||||
<div data-files-sync-item={status}>
|
||||
<dt className="text-xs text-muted-foreground">{t("item.label")}</dt>
|
||||
<dd className="flex items-center gap-1.5">
|
||||
<Icon className={cn("w-3.5 h-3.5 shrink-0", iconClass)} aria-hidden="true" />
|
||||
{t(`item.${status}`)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { useFilesSyncStore } from "@/stores/files-sync-store";
|
||||
import type { FilesRoot } from "@/lib/desktop/types";
|
||||
import type { FilesSyncRootStatus } from "@/lib/files-sync/types";
|
||||
import { rootName } from "./files-sync-text";
|
||||
|
||||
/*
|
||||
* The mass-delete question (FILES-SYNC-BRIEF: more than 50 items or more than half a
|
||||
* pair at once pauses the pair and asks). The settings page asks it in the folder's
|
||||
* row; everywhere else FilesSyncPrompts asks it in a dialog, so the user sees it
|
||||
* wherever they are.
|
||||
*/
|
||||
|
||||
let inlineHosts = 0;
|
||||
const inlineListeners = new Set<() => void>();
|
||||
const subscribeInline = (listener: () => void) => {
|
||||
inlineListeners.add(listener);
|
||||
return () => { inlineListeners.delete(listener); };
|
||||
};
|
||||
const inlineShown = () => inlineHosts > 0;
|
||||
|
||||
/** The settings page asks in its rows while it's open: the dialog stays away meanwhile. */
|
||||
export function useInlineMassDeleteQuestions(): void {
|
||||
useEffect(() => {
|
||||
inlineHosts += 1;
|
||||
inlineListeners.forEach((listener) => listener());
|
||||
return () => {
|
||||
inlineHosts -= 1;
|
||||
inlineListeners.forEach((listener) => listener());
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/** The question's text, for a root in `confirm-delete`. */
|
||||
export function useMassDeleteText(root: FilesRoot, massDelete: NonNullable<FilesSyncRootStatus["massDelete"]>): string {
|
||||
const t = useTranslations("files_sync");
|
||||
// The side the items went from: this computer's folder, or the Files folder.
|
||||
return massDelete.side === "local"
|
||||
? t("mass_delete.local", { count: massDelete.count, folder: rootName(root, t("drive.root_name")) })
|
||||
: t("mass_delete.remote", { count: massDelete.count, folder: root.remoteLabel || t("drive.root_name") });
|
||||
}
|
||||
|
||||
interface AnswerButtonsProps {
|
||||
rootId: string;
|
||||
count: number;
|
||||
/** After an answer went out. */
|
||||
onAnswered?: () => void;
|
||||
}
|
||||
|
||||
/** "Delete them" and "Bring them back", straight to the engine. */
|
||||
export function MassDeleteAnswers({ rootId, count, onAnswered }: AnswerButtonsProps) {
|
||||
const t = useTranslations("files_sync");
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const answer = (allow: boolean) => {
|
||||
if (!controller) return;
|
||||
setBusy(true);
|
||||
void controller.confirmMassDelete(rootId, allow).catch(() => {}).finally(() => {
|
||||
setBusy(false);
|
||||
onAnswered?.();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={busy || !controller} onClick={() => answer(false)} data-testid="files-sync-mass-delete-restore">
|
||||
{t("mass_delete.restore", { count })}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={busy || !controller} onClick={() => answer(true)} data-testid="files-sync-mass-delete-allow">
|
||||
{t("mass_delete.allow", { count })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The question inside a folder's row on the settings page. */
|
||||
export function MassDeleteQuestion({ root, status }: { root: FilesRoot; status: FilesSyncRootStatus }) {
|
||||
const massDelete = status.massDelete ?? { count: 0, side: "local" as const };
|
||||
const text = useMassDeleteText(root, massDelete);
|
||||
return (
|
||||
<div role="group" aria-label={text} className="mt-3 rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3" data-files-sync-question={root.id}>
|
||||
<p className="flex items-start gap-2 text-sm text-foreground">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<MassDeleteAnswers rootId={root.id} count={massDelete.count} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptDialog({ root, status, onLater }: { root: FilesRoot; status: FilesSyncRootStatus; onLater: () => void }) {
|
||||
const t = useTranslations("files_sync");
|
||||
const massDelete = status.massDelete!;
|
||||
const text = useMassDeleteText(root, massDelete);
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onLater}
|
||||
role="alertdialog"
|
||||
title={t("mass_delete.title", { count: massDelete.count })}
|
||||
description={text}
|
||||
showClose={false}
|
||||
preventOutsideClose
|
||||
testId="files-sync-mass-delete-dialog"
|
||||
icon={(
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={onLater}>{t("mass_delete.later")}</Button>
|
||||
<MassDeleteAnswers rootId={root.id} count={massDelete.count} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide: asks about a mass delete wherever the user is, one root at a time.
|
||||
* "Decide later" hides it for this question; the folder's row on the settings page
|
||||
* still asks, and the pair stays paused until then.
|
||||
*/
|
||||
export function FilesSyncPrompts() {
|
||||
const available = useFilesSyncStore((s) => s.available);
|
||||
const roots = useFilesSyncStore((s) => s.roots);
|
||||
const status = useFilesSyncStore((s) => s.status);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const pageAsks = useSyncExternalStore(subscribeInline, inlineShown, () => false);
|
||||
const [later, setLater] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
if (!available || !controller || pageAsks) return null;
|
||||
const keyOf = (s: FilesSyncRootStatus) => `${s.rootId}:${s.massDelete?.side}:${s.massDelete?.count}`;
|
||||
const root = roots.find((r) => {
|
||||
const s = status[r.id];
|
||||
return s?.state === "confirm-delete" && !!s.massDelete && !later.has(keyOf(s));
|
||||
});
|
||||
if (!root) return null;
|
||||
const rootStatus = status[root.id];
|
||||
return (
|
||||
<PromptDialog
|
||||
key={keyOf(rootStatus)}
|
||||
root={root}
|
||||
status={rootStatus}
|
||||
onLater={() => setLater((prev) => new Set(prev).add(keyOf(rootStatus)))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, CloudOff, Loader2, Lock, Pause, RefreshCw, type AppIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FilesSyncRootState, FilesSyncRootStatus } from "@/lib/files-sync/types";
|
||||
import { filesSyncErrorKey, syncTimeOptions } from "./files-sync-text";
|
||||
|
||||
const ICONS: Record<FilesSyncRootState, { icon: AppIcon; className: string }> = {
|
||||
starting: { icon: Loader2, className: "text-muted-foreground animate-spin motion-reduce:animate-none" },
|
||||
syncing: { icon: RefreshCw, className: "text-primary animate-spin motion-reduce:animate-none" },
|
||||
idle: { icon: CheckCircle2, className: "text-green-600 dark:text-green-400" },
|
||||
paused: { icon: Pause, className: "text-muted-foreground" },
|
||||
offline: { icon: CloudOff, className: "text-muted-foreground" },
|
||||
error: { icon: AlertCircle, className: "text-destructive" },
|
||||
"confirm-delete": { icon: AlertTriangle, className: "text-amber-600 dark:text-amber-400" },
|
||||
blocked: { icon: Lock, className: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
/** One line saying how a root is doing: up to date, syncing, paused, offline, stopped. */
|
||||
export function FilesSyncStatusLine({ status, paused, className }: {
|
||||
status: FilesSyncRootStatus | undefined;
|
||||
/** The root says it's paused; the engine may not have said so yet. */
|
||||
paused?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("files_sync");
|
||||
const format = useFormatter();
|
||||
const state: FilesSyncRootState = status?.state ?? (paused ? "paused" : "starting");
|
||||
|
||||
let text: string;
|
||||
switch (state) {
|
||||
case "idle":
|
||||
text = status?.lastSyncedAt
|
||||
? t("status.idle", { time: format.dateTime(new Date(status.lastSyncedAt), syncTimeOptions(status.lastSyncedAt)) })
|
||||
: t("status.idle_never");
|
||||
break;
|
||||
case "syncing":
|
||||
text = t("status.syncing", { done: status?.done ?? 0, pending: status?.pending ?? 0 });
|
||||
break;
|
||||
case "offline":
|
||||
text = t("status.offline", { count: status?.pending ?? 0 });
|
||||
break;
|
||||
case "error":
|
||||
text = t("status.error", { reason: t(filesSyncErrorKey(status?.errorKey)) });
|
||||
break;
|
||||
case "confirm-delete":
|
||||
text = t("status.confirm_delete");
|
||||
break;
|
||||
default:
|
||||
text = t(`status.${state}`);
|
||||
}
|
||||
const { icon: Icon, className: iconClass } = ICONS[state];
|
||||
return (
|
||||
<p className={cn("flex items-center gap-1.5 text-xs", state === "error" ? "text-destructive" : "text-muted-foreground", className)} data-files-sync-state={state}>
|
||||
<Icon className={cn("w-3.5 h-3.5 shrink-0", iconClass)} aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FilesRoot } from "@/lib/desktop/types";
|
||||
import type { FilesSyncActivityKind, FilesSyncItemStatus } from "@/lib/files-sync/types";
|
||||
|
||||
/** The engine's message keys, under `files_sync.errors.`. */
|
||||
export const FILES_SYNC_ERROR_KEYS = [
|
||||
"offline", "server_refused", "too_large", "name_invalid", "disk_full", "denied", "busy", "quota", "not_found", "policy", "failed",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The key inside the `files_sync` namespace for an engine message key. The engine may
|
||||
* write `offline`, `errors.offline` or `files_sync.errors.offline`; anything unknown
|
||||
* reads as `failed`, so a new key never shows raw.
|
||||
*/
|
||||
export function filesSyncErrorKey(key: string | undefined | null): string {
|
||||
const last = (key ?? "").split(".").pop() ?? "";
|
||||
return `errors.${(FILES_SYNC_ERROR_KEYS as readonly string[]).includes(last) ? last : "failed"}`;
|
||||
}
|
||||
|
||||
export const FILES_SYNC_ACTIVITY_KINDS: readonly FilesSyncActivityKind[] = [
|
||||
"uploaded", "downloaded", "folder-created", "renamed", "moved", "deleted", "trashed",
|
||||
"conflict", "renamed-for-disk", "too-large", "waiting", "mass-delete", "error",
|
||||
];
|
||||
|
||||
export const FILES_SYNC_ITEM_STATES: readonly FilesSyncItemStatus[] = ["synced", "syncing", "pending", "error", "online-only"];
|
||||
|
||||
/** "Show in Explorer", "Show in Finder", "Show in file manager". */
|
||||
export function showInKey(platform: string | undefined | null): string {
|
||||
if (platform === "win32") return "show_in.explorer";
|
||||
if (platform === "darwin") return "show_in.finder";
|
||||
return "show_in.file_manager";
|
||||
}
|
||||
|
||||
/** The name of a root to show: the drive's, or the last part of the folder's path. */
|
||||
export function rootName(root: FilesRoot, driveName: string): string {
|
||||
if (root.kind === "drive") return driveName;
|
||||
const parts = root.path.split(/[\\/]+/).filter(Boolean);
|
||||
return parts[parts.length - 1] ?? root.path;
|
||||
}
|
||||
|
||||
/** A time as "14:05" today and "3 Nov, 14:05" before. */
|
||||
export function syncTimeOptions(at: number, now = Date.now()): { hour: "numeric"; minute: "2-digit"; day?: "numeric"; month?: "short" } {
|
||||
const sameDay = new Date(at).toDateString() === new Date(now).toDateString();
|
||||
return sameDay
|
||||
? { hour: "numeric", minute: "2-digit" }
|
||||
: { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" };
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { getFilePreviewKind } from "@/lib/file-preview";
|
||||
import type { FileAppAction } from "@/lib/files-app-actions";
|
||||
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
|
||||
import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types";
|
||||
import { ShowInFolderMenuItem } from "@/components/files-sync/files-sync-item";
|
||||
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
@@ -1057,6 +1058,7 @@ export function FileBrowser({
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
|
||||
<ShowInFolderMenuItem nodeId={resource.id} onRan={closeMenu} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
|
||||
<FilePluginMenuEntries
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { FilesSyncDetailsRow, ShowInFolderButton } from "@/components/files-sync/files-sync-item";
|
||||
import {
|
||||
FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
@@ -157,6 +158,7 @@ export function FileDetailsPanel({
|
||||
{isStarred ? t("unstar") : t("star")}
|
||||
</Button>
|
||||
)}
|
||||
<ShowInFolderButton nodeId={resource.id} />
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row
|
||||
@@ -167,6 +169,7 @@ export function FileDetailsPanel({
|
||||
{resource.lastModified && <Row label={t("modified")} value={formatFullDate(resource.lastModified)} />}
|
||||
<Row label={t("sharing")} value={sharing} />
|
||||
<Row label={t("path")} value={path} mono />
|
||||
<FilesSyncDetailsRow nodeId={resource.id} />
|
||||
</dl>
|
||||
{pluginProps && <PluginSlot name="file-preview-section" className="w-full" extraProps={pluginProps} />}
|
||||
</div>,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
import { FilesSyncBadge } from "@/components/files-sync/files-sync-item";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
import { TOUCH_HIT_AREA } from "@/components/ui/button";
|
||||
import { FILE_LIST_COLUMNS } from "./file-list-layout";
|
||||
@@ -274,6 +275,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
|
||||
</span>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
<FilesSyncBadge nodeId={resource.id} />
|
||||
</div>
|
||||
{touch && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
@@ -487,6 +489,7 @@ function FileTile({ resource, isSelected, isFocused, isTabStop, isCut, selecting
|
||||
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
|
||||
</span>
|
||||
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
|
||||
<FilesSyncBadge nodeId={resource.id} className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, LayoutGrid, LayoutList,
|
||||
PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard,
|
||||
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, FolderInput,
|
||||
} from "@/components/icons";
|
||||
import { Button, TOUCH_HIT_AREA } from "@/components/ui/button";
|
||||
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 { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuCheckItem } 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";
|
||||
@@ -465,27 +465,13 @@ function DetailsToggle({ showDetails, onToggle, label }: { showDetails: boolean;
|
||||
);
|
||||
}
|
||||
|
||||
function SortItem({ label, checked, onClick, icon: Icon }: {
|
||||
function SortItem({ label, checked, onClick }: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onClick: () => void;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
onClick={onClick}
|
||||
className="w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted focus:outline-none focus:bg-muted"
|
||||
>
|
||||
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
|
||||
{checked && <Check className="w-4 h-4" />}
|
||||
</span>
|
||||
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
);
|
||||
return <ContextMenuCheckItem kind="radio" label={label} checked={checked} onClick={onClick} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ export {
|
||||
IconBookmark as Bookmark,
|
||||
IconBookmarkPlus as BookmarkPlus,
|
||||
IconBox as Box,
|
||||
IconBraces as Braces,
|
||||
IconBrain as Brain,
|
||||
IconBriefcase as Briefcase,
|
||||
IconBug as Bug,
|
||||
|
||||
@@ -22,7 +22,8 @@ import { accountAvatarName, getMaxAccounts } from "@/lib/account-utils";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { quotaLevel, type StorageQuota } from "@/lib/quota";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
@@ -304,61 +305,11 @@ export function NavigationRail({
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
|
||||
const logoutBtnRef = useRef<HTMLButtonElement>(null);
|
||||
// Portalled to <body>, so without explicit focus handling the menu is
|
||||
// unreachable for keyboard and screen reader users (#719).
|
||||
const closeLogoutMenu = useCallback(() => setLogoutMenuOpen(false), []);
|
||||
const { menuRef: logoutPopoverRef, onKeyDown: onLogoutMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
|
||||
open: logoutMenuOpen,
|
||||
onClose: closeLogoutMenu,
|
||||
triggerRef: logoutBtnRef,
|
||||
});
|
||||
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
|
||||
// Sign out: the app's own menu, as every ⋮ and right-click has it.
|
||||
const logoutMenu = useContextMenu<null>();
|
||||
const logoutMenuOpen = logoutMenu.contextMenu.isOpen;
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
|
||||
const updateLogoutPosition = useCallback(() => {
|
||||
if (!logoutBtnRef.current) return;
|
||||
const rect = logoutBtnRef.current.getBoundingClientRect();
|
||||
setLogoutPopoverStyle(
|
||||
isDocumentRTL()
|
||||
? {
|
||||
position: "fixed",
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
: {
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Position before the first paint (same reasoning as the quota popover above).
|
||||
useLayoutEffect(() => {
|
||||
if (logoutMenuOpen) updateLogoutPosition();
|
||||
}, [logoutMenuOpen, updateLogoutPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoutMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
logoutBtnRef.current?.contains(e.target as Node) ||
|
||||
logoutPopoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLogoutMenuOpen(false);
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setLogoutMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [logoutMenuOpen, logoutPopoverRef]);
|
||||
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -952,10 +903,9 @@ export function NavigationRail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout button with popover */}
|
||||
{/* Sign out, with a menu */}
|
||||
<button
|
||||
ref={logoutBtnRef}
|
||||
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
|
||||
onClick={(ev) => (logoutMenuOpen ? logoutMenu.closeContextMenu() : logoutMenu.openContextMenuFrom(ev.currentTarget, null, "start"))}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t("sign_out")}
|
||||
aria-label={t("sign_out")}
|
||||
@@ -964,37 +914,18 @@ export function NavigationRail({
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{logoutMenuOpen && createPortal(
|
||||
<div
|
||||
ref={logoutPopoverRef}
|
||||
onKeyDown={onLogoutMenuKeyDown}
|
||||
style={logoutPopoverStyle}
|
||||
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||
role="menu"
|
||||
aria-label={t("sign_out")}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setLogoutMenuOpen(false); logout(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out")}
|
||||
</button>
|
||||
{accounts.length > 1 && (
|
||||
<button
|
||||
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-start text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ContextMenu
|
||||
ref={logoutMenu.menuRef}
|
||||
isOpen={logoutMenuOpen}
|
||||
position={logoutMenu.contextMenu.position}
|
||||
onClose={logoutMenu.closeContextMenu}
|
||||
label={t("sign_out")}
|
||||
>
|
||||
<ContextMenuItem icon={LogOut} label={t("sign_out")} onClick={() => { logoutMenu.closeContextMenu(); logout(); }} />
|
||||
{accounts.length > 1 && (
|
||||
<ContextMenuItem icon={LogOut} label={t("sign_out_all")} onClick={() => { logoutMenu.closeContextMenu(); logoutAll(); }} />
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { MoreVertical, Terminal, Bell } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { AccountSwitcher } from "@/components/layout/account-switcher";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { AppSidePanelPhoneButton } from "@/components/plugins/app-side-panel";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
|
||||
import { openCommandPalette } from "@/lib/app-commands";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -41,29 +41,17 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
|
||||
const tNotifications = useTranslations("notification_center");
|
||||
const unread = useUnreadNotificationCount();
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open, menuRef]);
|
||||
const menu = useContextMenu<null>();
|
||||
const open = menu.contextMenu.isOpen;
|
||||
const close = menu.closeContextMenu;
|
||||
|
||||
const appItems = menuItems?.(close);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
size="icon-lg"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={(e) => (open ? close() : menu.openContextMenuFrom(e.currentTarget, null, "end"))}
|
||||
className={cn(open && "bg-accent")}
|
||||
label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")}
|
||||
title=""
|
||||
@@ -75,14 +63,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
|
||||
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
|
||||
)}
|
||||
</IconButton>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
onKeyDown={onKeyDown}
|
||||
role="menu"
|
||||
aria-label={t("more")}
|
||||
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
|
||||
>
|
||||
<ContextMenu ref={menu.menuRef} isOpen={open} position={menu.contextMenu.position} onClose={close} label={t("more")}>
|
||||
{appItems && (
|
||||
<>
|
||||
{appItems}
|
||||
@@ -101,8 +82,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
|
||||
onClick={() => { close(); openCommandPalette(); }}
|
||||
/>
|
||||
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
|
||||
</div>
|
||||
)}
|
||||
</ContextMenu>
|
||||
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in";
|
||||
import { claimFilesControls } from "@/lib/files-sync/controls";
|
||||
|
||||
/**
|
||||
* The window's controls over the app, with no title bar row: see
|
||||
@@ -13,7 +14,10 @@ import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in";
|
||||
export function WindowControlsHost() {
|
||||
// In the first render of every page, before any frame exists: a frame can reach the
|
||||
// bridge, and whoever comes after this gets nothing.
|
||||
if (typeof window !== "undefined") claimBrowserSignIn();
|
||||
if (typeof window !== "undefined") {
|
||||
claimBrowserSignIn();
|
||||
claimFilesControls();
|
||||
}
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, ChevronRight } from "@/components/icons";
|
||||
import {
|
||||
Archive, ArrowLeft, ArrowRight, BookOpen, Calendar, CalendarDays, CalendarPlus, Check, CheckSquare, ChevronDown,
|
||||
ChevronRight, ChevronUp, Clipboard, Copy, Download, ExternalLink, FileText, FolderInput, Forward, Inbox, Info, Keyboard,
|
||||
Lock, LogOut, Mail, MailOpen, Maximize2, Minus, Paperclip, PanelRight, Printer, RefreshCw, Reply, ReplyAll, Save, Scissors,
|
||||
Search, Send, Settings, ShieldAlert, SquarePen, Star, Trash2, Undo, Redo, Upload, UserPlus, Users, Folder, ZoomIn, ZoomOut,
|
||||
Server, RotateCcw, LayoutGrid, LayoutList, LayoutTemplate, Zap, Power,
|
||||
} from "@/components/icons";
|
||||
import { CONTEXT_MENU_PANEL_CLASS, MenuShortcut } from "@/components/ui/context-menu";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar";
|
||||
import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types";
|
||||
@@ -23,6 +30,39 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
|
||||
const LINE = "[data-menu-line]";
|
||||
|
||||
type Glyph = React.ComponentType<{ className?: string }>;
|
||||
|
||||
/**
|
||||
* The icon of each line of the shell's menus, by its id (src/main/menu.ts in the desktop
|
||||
* repo; a role's line comes as `role.<role>`), so the bar's menus look like every other
|
||||
* menu in the app. A shell that sends no ids gets the lines without icons.
|
||||
*/
|
||||
const MENU_BAR_ICONS: Record<string, Glyph> = {
|
||||
"file.newMessage": SquarePen, "file.newEvent": CalendarPlus, "file.newTask": CheckSquare, "file.newContact": UserPlus,
|
||||
"file.quickCompose": Zap, "file.newWindow": ExternalLink, "file.print": Printer, "file.savePdf": Download,
|
||||
"file.import": Upload, "file.importMenu": Upload, "file.importCalendar": Calendar, "file.importContacts": Users,
|
||||
"file.settings": Settings, "app.settings": Settings, "file.switchMode": Server, "file.lock": Lock, "file.close": Minus,
|
||||
"file.quit": Power, "role.quit": Power, "role.close": Minus,
|
||||
"role.undo": Undo, "role.redo": Redo, "role.cut": Scissors, "role.copy": Copy, "role.paste": Clipboard,
|
||||
"role.selectAll": CheckSquare, "role.selectall": CheckSquare, "edit.search": Search,
|
||||
"view.refresh": RefreshCw, "view.calendar.day": CalendarDays, "view.calendar.week": CalendarDays,
|
||||
"view.calendar.month": CalendarDays, "view.calendar.agenda": CalendarDays, "view.readingPane": PanelRight,
|
||||
"view.layout.split": PanelRight, "view.layout.horizontal": LayoutList, "view.layout.focus": LayoutTemplate,
|
||||
"view.zoomIn": ZoomIn, "view.zoomOut": ZoomOut, "view.zoomReset": RotateCcw,
|
||||
"role.togglefullscreen": Maximize2, "role.toggleFullScreen": Maximize2,
|
||||
"compose.send": Send, "compose.saveDraft": Save, "compose.attach": Paperclip,
|
||||
"message.reply": Reply, "message.replyAll": ReplyAll, "message.forward": Forward, "message.markRead": MailOpen,
|
||||
"message.markUnread": Mail, "message.star": Star, "message.move": FolderInput, "message.archive": Archive,
|
||||
"message.spam": ShieldAlert, "message.delete": Trash2, "message.openWindow": ExternalLink,
|
||||
"go.inbox": Inbox, "go.sent": Send, "go.drafts": FileText, "go.nextMessage": ChevronDown, "go.previousMessage": ChevronUp,
|
||||
"go.nextUnread": MailOpen, "go.mail": Mail, "go.calendar": Calendar, "go.contacts": Users, "go.files": Folder,
|
||||
"go.back": ArrowLeft, "go.forward": ArrowRight, "go.today": CalendarDays, "go.nextPeriod": ChevronRight,
|
||||
"go.previousPeriod": ArrowLeft,
|
||||
"window.minimize": Minus, "role.minimize": Minus, "window.cycle": LayoutGrid,
|
||||
"help.shortcuts": Keyboard, "help.docs": BookOpen, "help.updates": Download, "help.restart": RefreshCw, "help.about": Info,
|
||||
"role.about": Info, "app.lock": Lock, "file.unlock": Lock, "app.logout": LogOut,
|
||||
};
|
||||
const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`;
|
||||
|
||||
type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean };
|
||||
@@ -363,6 +403,7 @@ export function WindowMenuBar() {
|
||||
const isSub = item.type === "submenu";
|
||||
const checkable = item.type === "checkbox" || item.type === "radio";
|
||||
const opened = level === "panel" && isSub && sub === index;
|
||||
const LineIcon = item.id ? MENU_BAR_ICONS[item.id] : undefined;
|
||||
return (
|
||||
<div key={item.key} className="relative">
|
||||
<button
|
||||
@@ -384,24 +425,28 @@ export function WindowMenuBar() {
|
||||
if (isSub) openSub(index);
|
||||
else run(item);
|
||||
}}
|
||||
// The shared menu's row (components/ui/context-menu.tsx): icon column, label,
|
||||
// the key as a keycap, the submenu's chevron.
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm outline-none",
|
||||
item.enabled ? "focus:bg-muted" : "cursor-default text-muted-foreground/60",
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-start text-sm outline-none",
|
||||
item.enabled ? "focus:bg-muted" : "cursor-default opacity-50",
|
||||
opened && "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 justify-center" aria-hidden="true">
|
||||
{checkable && item.checked && (item.type === "radio" ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : <Check className="h-3.5 w-3.5" />)}
|
||||
{checkable && item.checked ? <Check className="h-4 w-4" /> : LineIcon ? <LineIcon className="h-4 w-4" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate" data-menu-label="">
|
||||
<Label text={item.label} at={item.mnemonic} cue={cue} />
|
||||
</span>
|
||||
{item.accelerator && (
|
||||
<span className="shrink-0 ps-6 text-xs text-muted-foreground" data-menu-keys="" aria-hidden="true">
|
||||
{menuKeyText(item.accelerator, getDesktopBridge()?.platform)}
|
||||
</span>
|
||||
<MenuShortcut
|
||||
keys={menuKeyText(item.accelerator, getDesktopBridge()?.platform)}
|
||||
className="ms-6"
|
||||
data-menu-keys=""
|
||||
/>
|
||||
)}
|
||||
{isSub && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
{isSub && <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -456,7 +501,7 @@ export function WindowMenuBar() {
|
||||
aria-label={menu.label}
|
||||
data-menu-panel=""
|
||||
tabIndex={-1}
|
||||
className="absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
|
||||
className={cn("absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(22rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto py-1 text-foreground outline-none", CONTEXT_MENU_PANEL_CLASS)}
|
||||
>
|
||||
{renderLines(openItems, "panel")}
|
||||
</div>
|
||||
@@ -466,7 +511,7 @@ export function WindowMenuBar() {
|
||||
role="menu"
|
||||
aria-label={openItems[sub]?.label}
|
||||
data-menu-subpanel=""
|
||||
className="fixed z-10 min-w-44 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
className={cn("fixed z-10 min-w-44 max-w-[min(22rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto py-1 text-foreground", CONTEXT_MENU_PANEL_CLASS)}
|
||||
style={{ top: -9999, left: -9999 }}
|
||||
>
|
||||
{renderLines(subItems, "subpanel")}
|
||||
|
||||
@@ -22,6 +22,9 @@ const stub = vi.hoisted(() => ({
|
||||
closes: [] as string[],
|
||||
mounts: [] as string[],
|
||||
restore: null as null | ((data: Record<string, unknown>) => void),
|
||||
// A slow runner: the mount is counted a task after the composer is on
|
||||
// screen, as when React runs its effects late (CI, compose-p2-1).
|
||||
lateMount: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
@@ -42,7 +45,8 @@ vi.mock('@/components/email/email-composer', async () => {
|
||||
}) => {
|
||||
const closed = useRef(false);
|
||||
useEffect(() => {
|
||||
stub.mounts.push(mode);
|
||||
if (stub.lateMount) setTimeout(() => stub.mounts.push(mode), 0);
|
||||
else stub.mounts.push(mode);
|
||||
stub.restore = onRestoreDiscarded ?? null;
|
||||
// The old host moved the composer between dock and pane for ever
|
||||
// (React error 185 in the browser); stop it here instead of hanging.
|
||||
@@ -122,6 +126,7 @@ beforeEach(() => {
|
||||
stub.closes = [];
|
||||
stub.mounts = [];
|
||||
stub.restore = null;
|
||||
stub.lateMount = false;
|
||||
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) });
|
||||
@@ -157,24 +162,39 @@ afterEach(() => {
|
||||
|
||||
const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode');
|
||||
|
||||
describe('Compose while a reply is docked (compose-p2-1)', () => {
|
||||
for (const action of ['stub reply', 'stub forward']) {
|
||||
it(`opens a new message in the pane after ${action}`, async () => {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText(action));
|
||||
const reply = await screen.findByTestId('stub-composer');
|
||||
expect(reply.closest('[data-reply-dock]')).not.toBeNull();
|
||||
// Compose opens after an await, outside the click's act(): the composer is on
|
||||
// screen before React runs its mount effect. Count mounts once they ran, and
|
||||
// tell a remount by the element on screen, which a remount replaces.
|
||||
async function composeOverDockedReply(action: string, first: string) {
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText(action));
|
||||
const reply = await screen.findByTestId('stub-composer');
|
||||
expect(reply.closest('[data-reply-dock]')).not.toBeNull();
|
||||
await waitFor(() => expect(stub.mounts).toEqual([first]));
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(composerMode()).toBe('compose'));
|
||||
// The new message takes the pane, not the dock, and stays put.
|
||||
expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull();
|
||||
const mounted = stub.mounts.length;
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(stub.mounts.length).toBe(mounted);
|
||||
expect(composerMode()).toBe('compose');
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(composerMode()).toBe('compose'));
|
||||
// The new message takes the pane, not the dock, and stays put.
|
||||
const composer = screen.getByTestId('stub-composer');
|
||||
expect(composer.closest('[data-reply-dock]')).toBeNull();
|
||||
await waitFor(() => expect(stub.mounts).toEqual([first, 'compose']));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(stub.mounts).toEqual([first, 'compose']);
|
||||
expect(screen.getByTestId('stub-composer')).toBe(composer);
|
||||
expect(composerMode()).toBe('compose');
|
||||
}
|
||||
|
||||
describe('Compose while a reply is docked (compose-p2-1)', () => {
|
||||
for (const [action, first] of [['stub reply', 'reply'], ['stub forward', 'forward']] as const) {
|
||||
it(`opens a new message in the pane after ${action}`, async () => {
|
||||
await composeOverDockedReply(action, first);
|
||||
});
|
||||
}
|
||||
|
||||
it('counts one mount when the composer mounts late, as on a slow runner', async () => {
|
||||
stub.lateMount = true;
|
||||
await composeOverDockedReply('stub reply', 'reply');
|
||||
});
|
||||
});
|
||||
|
||||
describe('another composer while one is open (compose-p2-2)', () => {
|
||||
@@ -182,7 +202,7 @@ describe('another composer while one is open (compose-p2-2)', () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['reply']));
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(composerMode()).toBe('compose'));
|
||||
@@ -194,7 +214,7 @@ describe('another composer while one is open (compose-p2-2)', () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['reply']));
|
||||
|
||||
fireEvent.click(screen.getByText(action));
|
||||
await waitFor(() => expect(composerMode()).toBe(mode));
|
||||
@@ -206,11 +226,10 @@ describe('another composer while one is open (compose-p2-2)', () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
const mounted = stub.mounts.length;
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['compose']));
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1));
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['compose', 'compose']));
|
||||
expect(stub.closes).toEqual(['compose']);
|
||||
});
|
||||
|
||||
@@ -218,13 +237,14 @@ describe('another composer while one is open (compose-p2-2)', () => {
|
||||
stub.close = 'fails';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(await screen.findByText('stub reply'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
const mounted = stub.mounts.length;
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['reply']));
|
||||
const composer = screen.getByTestId('stub-composer');
|
||||
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(stub.closes).toEqual(['reply']);
|
||||
expect(stub.mounts.length).toBe(mounted);
|
||||
expect(stub.mounts).toEqual(['reply']);
|
||||
expect(screen.getByTestId('stub-composer')).toBe(composer);
|
||||
expect(composerMode()).toBe('reply');
|
||||
});
|
||||
});
|
||||
@@ -235,7 +255,7 @@ describe('Undo after the bin', () => {
|
||||
stub.close = 'saves';
|
||||
render(<MailApp />);
|
||||
fireEvent.click(screen.getByText('stub compose'));
|
||||
await screen.findByTestId('stub-composer');
|
||||
await waitFor(() => expect(stub.mounts).toEqual(['compose']));
|
||||
const restore = stub.restore!;
|
||||
|
||||
act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' }));
|
||||
|
||||
@@ -6680,7 +6680,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
void handleCompose();
|
||||
}}
|
||||
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
|
||||
? <div ref={setSelectionPaneHost} className="m-auto p-8" />
|
||||
? <div ref={setSelectionPaneHost} className="m-auto w-full p-8" />
|
||||
: openFailure && !isLoadingEmail
|
||||
? <ReaderOpenFailure offline={openFailure.offline} onRetry={() => void handleEmailSelect(openFailure.target)} />
|
||||
: undefined}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
// In the desktop app a plugin's confirm or alert is a window of its own. The host
|
||||
// names the plugin there, the plugin's text stays text, and the ⋮ menu still
|
||||
// turns the plugin off. Prompts stay in the page.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: { name?: string }) => (values?.name ? `${key}:${values.name}` : key),
|
||||
}));
|
||||
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => null }));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitDialog, awaitPrompt, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import type { BulwarkDesktopBridge, DesktopDialogWindowAnswer, DesktopDialogWindowRequest } from '@/lib/desktop/types';
|
||||
|
||||
type Show = (request: DesktopDialogWindowRequest) => Promise<DesktopDialogWindowAnswer>;
|
||||
let answer: (value: DesktopDialogWindowAnswer) => void;
|
||||
let show: ReturnType<typeof vi.fn<Show>>;
|
||||
let disablePlugin: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
show = vi.fn<Show>(() => new Promise((resolve) => (answer = resolve)));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm: vi.fn(), show, cancel: vi.fn() } } as unknown as BulwarkDesktopBridge;
|
||||
disablePlugin = vi.fn();
|
||||
usePluginStore.setState({ plugins: [{ id: 'elw', name: 'External Link Warning' }] as never, disablePlugin } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const LINK = {
|
||||
pluginId: 'elw',
|
||||
kind: 'confirm' as const,
|
||||
title: 'Open external link?',
|
||||
message: 'You\'re about to visit **github.com**.\n<img src=x onerror=alert(1)>',
|
||||
confirmLabel: 'Continue',
|
||||
};
|
||||
|
||||
describe('plugin dialogs in the desktop app', () => {
|
||||
it('asks in a window with the plugin named in the host\'s line and its text as text', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let result: Promise<boolean>;
|
||||
act(() => { result = awaitDialog(LINK); });
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({
|
||||
title: 'Open external link?',
|
||||
source: 'from_plugin:External Link Warning',
|
||||
message: [{ text: 'You\'re about to visit ' }, { text: 'github.com', bold: true }, { text: '.\n<img src=x onerror=alert(1)>' }],
|
||||
buttons: [{ label: 'cancel', variant: 'secondary' }, { label: 'Continue', variant: 'primary' }],
|
||||
defaultIndex: 1,
|
||||
menu: { label: 'plugin_menu:External Link Warning', items: [{ id: 'turn-off', label: 'turn_off:External Link Warning', destructive: true }] },
|
||||
});
|
||||
|
||||
await act(async () => answer({ button: 1 }));
|
||||
await expect(result!).resolves.toBe(true);
|
||||
expect(head()).toBeNull();
|
||||
});
|
||||
|
||||
it('Cancel or Escape is no; "Turn off" in the menu turns the plugin off', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let result: Promise<boolean>;
|
||||
act(() => { result = awaitDialog(LINK); });
|
||||
await act(async () => answer(null));
|
||||
await expect(result!).resolves.toBe(false);
|
||||
|
||||
act(() => { result = awaitDialog(LINK); });
|
||||
await act(async () => answer({ menu: 'turn-off' }));
|
||||
await expect(result!).resolves.toBe(false);
|
||||
expect(disablePlugin).toHaveBeenCalledWith('elw');
|
||||
});
|
||||
|
||||
it('an alert has one button, and a destructive confirm starts on Cancel', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitDialog({ pluginId: 'elw', kind: 'alert', title: 'Done', message: '' }); });
|
||||
expect(show.mock.calls[0][0]).toMatchObject({ buttons: [{ label: 'ok', variant: 'primary' }], defaultIndex: 0 });
|
||||
await act(async () => answer({ button: 0 }));
|
||||
|
||||
act(() => { void awaitDialog({ ...LINK, danger: true }); });
|
||||
expect(show.mock.calls[1][0]).toMatchObject({ buttons: [{}, { variant: 'destructive' }], defaultIndex: 0 });
|
||||
});
|
||||
|
||||
it('a prompt stays in the page', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitPrompt({ pluginId: 'elw', kind: 'prompt', title: 'Name?', message: '', fields: [{ name: 'n', label: 'Name' }] }); });
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('dialog', { name: 'Name?' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a plugin that goes takes its window with it', () => {
|
||||
render(<PluginDialogHost />);
|
||||
act(() => { void awaitDialog(LINK); });
|
||||
const cancel = window.bulwarkDesktop!.dialogs!.cancel as ReturnType<typeof vi.fn>;
|
||||
act(() => resolveHead(false));
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@
|
||||
// { view, context }), and so does api.ui.openPanel there.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { formatShortcutCombo } from '@/lib/shortcut-keys';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PanelRightClose, PanelRightOpen } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -72,7 +73,7 @@ export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcu
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
className={cn('h-8 w-8 shrink-0', className)}
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
title={shortcut ? `${label} (${formatShortcutCombo(shortcut)})` : label}
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
aria-keyshortcuts={shortcut ?? undefined}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
// (dialog-anchor.ts), with a one-line header naming the plugin. `fill: true`
|
||||
// gives either form a fixed height the plugin's frame fills (a chat), so an
|
||||
// anchored, filled dialog is a tall popover beside the control.
|
||||
//
|
||||
// In the desktop app a confirm or alert is a window of its own instead
|
||||
// (lib/desktop/dialog-window.ts), with the same parts: the title, the
|
||||
// "From the plugin" line, the ⋮ menu and the buttons. Prompts and custom
|
||||
// dialogs stay here: they hold fields and the plugin's own frame.
|
||||
|
||||
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
@@ -25,8 +30,11 @@ import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
|
||||
import { useContextMenu } from '@/hooks/use-context-menu';
|
||||
import { iconForName, Loader2, MoreVertical, Power, Puzzle, X } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { askInWindow, dialogWindowsAvailable } from '@/lib/desktop/dialog-window';
|
||||
import { PluginIframeSlot } from './plugin-iframe-slot';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
|
||||
@@ -65,12 +73,77 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<>
|
||||
{base && <PluginDialogView key={base.id} current={base} layer={top ? 'below' : 'only'} />}
|
||||
{top && <PluginDialogView key={top.id} current={top} layer="above" />}
|
||||
{base && (inWindow(base)
|
||||
? <PluginDialogWindow key={base.id} current={base} layer={top ? 'below' : 'only'} />
|
||||
: <PluginDialogView key={base.id} current={base} layer={top ? 'below' : 'only'} />)}
|
||||
{top && (inWindow(top)
|
||||
? <PluginDialogWindow key={top.id} current={top} layer="above" />
|
||||
: <PluginDialogView key={top.id} current={top} layer="above" />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A confirm or alert, in the desktop app: a window of its own. */
|
||||
function inWindow(request: DialogRequest): boolean {
|
||||
return (request.kind === 'confirm' || request.kind === 'alert') && dialogWindowsAvailable();
|
||||
}
|
||||
|
||||
const TURN_OFF = 'turn-off';
|
||||
|
||||
/**
|
||||
* A plugin's confirm or alert in a window of the app's own. The host names the plugin
|
||||
* in the window's own line, which the plugin's text can't change, and the ⋮ menu
|
||||
* still turns it off. If the shell refuses, the dialog is drawn in the page.
|
||||
*/
|
||||
function PluginDialogWindow({ current, layer }: { current: DialogRequest; layer: 'only' | 'below' | 'above' }): React.JSX.Element | null {
|
||||
const t = useTranslations('plugins.dialog');
|
||||
const pluginName = usePluginStore((s) => s.plugins.find((p) => p.id === current.pluginId)?.name);
|
||||
const disablePlugin = usePluginStore((s) => s.disablePlugin);
|
||||
const [refused, setRefused] = useState(false);
|
||||
const name = pluginName ?? current.pluginId;
|
||||
|
||||
useEffect(() => {
|
||||
if (refused) return;
|
||||
const confirm = current.kind === 'confirm';
|
||||
const okLabel = current.confirmLabel ?? (confirm ? t('confirm') : t('ok'));
|
||||
const buttons = confirm
|
||||
? [{ label: current.cancelLabel ?? t('cancel'), variant: 'secondary' as const }, { label: okLabel, variant: current.danger ? 'destructive' as const : 'primary' as const }]
|
||||
: [{ label: okLabel, variant: 'primary' as const }];
|
||||
const yes = buttons.length - 1;
|
||||
const asked = askInWindow({
|
||||
title: current.title || name,
|
||||
message: current.message,
|
||||
boldMarks: true,
|
||||
source: t('from_plugin', { name }),
|
||||
buttons,
|
||||
// As in the page: a destructive confirm only says yes from its own button.
|
||||
defaultIndex: confirm && current.danger ? 0 : yes,
|
||||
menu: { label: t('plugin_menu', { name }), items: [{ id: TURN_OFF, label: t('turn_off', { name }), destructive: true }] },
|
||||
});
|
||||
if (!asked) return;
|
||||
let live = true;
|
||||
void asked.answer.then((answer) => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
if (answer === 'failed') {
|
||||
setRefused(true);
|
||||
} else if (answer && 'menu' in answer && answer.menu === TURN_OFF) {
|
||||
cancelForPlugin(current.pluginId);
|
||||
disablePlugin(current.pluginId);
|
||||
} else {
|
||||
resolveDialog(current.id, Boolean(answer && 'button' in answer && answer.button === yes));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
asked.cancel();
|
||||
};
|
||||
}, [current, name, refused, t, disablePlugin]);
|
||||
|
||||
return refused ? <PluginDialogView current={current} layer={layer} /> : null;
|
||||
}
|
||||
|
||||
const DIALOG_BUTTON_VARIANT = { primary: 'default', secondary: 'outline', ghost: 'ghost', destructive: 'destructive' } as const;
|
||||
const DIALOG_BUTTON_ID_RE = /^[A-Za-z0-9._-]{1,40}$/;
|
||||
export const MAX_DIALOG_BUTTONS = 4;
|
||||
@@ -467,21 +540,13 @@ function FooterIcon({ name }: { name?: string }) {
|
||||
/** The "⋯" menu in a plugin dialog's header: the way out of a plugin that keeps asking. */
|
||||
function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOff: () => void; compact?: boolean }) {
|
||||
const t = useTranslations('plugins.dialog');
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown, true);
|
||||
return () => document.removeEventListener('mousedown', onDown, true);
|
||||
}, [open]);
|
||||
const menu = useContextMenu<null>();
|
||||
const open = menu.contextMenu.isOpen;
|
||||
return (
|
||||
<div ref={ref} className="relative flex-shrink-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={(e) => (open ? menu.closeContextMenu() : menu.openContextMenuFrom(e.currentTarget, null, 'end'))}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t('plugin_menu', { name })}
|
||||
@@ -490,19 +555,14 @@ function PluginDialogMenu({ name, onTurnOff, compact }: { name: string; onTurnOf
|
||||
>
|
||||
<MoreVertical className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { setOpen(false); onTurnOff(); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 transition-colors duration-150 text-destructive hover:bg-destructive/10 focus:outline-none focus:bg-destructive/10"
|
||||
>
|
||||
<Power className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1">{t('turn_off', { name })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu ref={menu.menuRef} isOpen={open} position={menu.contextMenu.position} onClose={menu.closeContextMenu} label={t('plugin_menu', { name })}>
|
||||
<ContextMenuItem
|
||||
icon={Power}
|
||||
label={t('turn_off', { name })}
|
||||
destructive
|
||||
onClick={() => { menu.closeContextMenu(); onTurnOff(); }}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { getDesktopPolicy, onDesktopPolicyChange } from "@/lib/desktop/policy";
|
||||
import { canDownload } from "@/lib/device-policy";
|
||||
import { claimFilesControls } from "@/lib/files-sync/controls";
|
||||
import { FilesSyncHost as Host, type HostAccount } from "@/lib/files-sync/host";
|
||||
import { registerFilesSyncLifecycle, setRunningFilesSyncHost } from "@/lib/files-sync/lifecycle";
|
||||
import { scopeDecided } from "@/lib/offline/offline-mode";
|
||||
import { subscribePushStateChanges } from "@/lib/push-events";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useFilesSyncStore } from "@/stores/files-sync-store";
|
||||
|
||||
registerFilesSyncLifecycle();
|
||||
|
||||
/** `FilesFolderSyncEnabled` forced off. Main removes the folders itself; this keeps the engine off them meanwhile. */
|
||||
function folderSyncForcedOff(): boolean {
|
||||
const entry = getDesktopPolicy().FilesFolderSyncEnabled;
|
||||
return entry.locked && entry.value === false;
|
||||
}
|
||||
|
||||
/** A definite no from the organisation. An unknown policy isn't one. */
|
||||
function downloadsForbidden(account: string): boolean {
|
||||
return scopeDecided(account) && !canDownload(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files on this computer, in the desktop app's main window (it's under MainWindowOnly): starts
|
||||
* the sync engine for the signed-in accounts and keeps it fed with push, the network and the
|
||||
* policies. Renders nothing. In a browser, or a shell without the files bridge, it does nothing.
|
||||
*/
|
||||
export function FilesSyncHost() {
|
||||
const hostRef = useRef<Host | null>(null);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const revision = useAuthStore((s) => s.connectedAccountsRevision);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = getDesktopBridge();
|
||||
const controls = bridge?.files ? claimFilesControls() : null;
|
||||
if (!bridge || !controls) return;
|
||||
const store = useFilesSyncStore.getState();
|
||||
const host = new Host({
|
||||
controls,
|
||||
platform: bridge.platform,
|
||||
publish: (patch) => useFilesSyncStore.getState().publish(patch),
|
||||
setController: (controller) => useFilesSyncStore.getState().setController(controller),
|
||||
folderSyncForcedOff,
|
||||
downloadsForbidden,
|
||||
});
|
||||
hostRef.current = host;
|
||||
setRunningFilesSyncHost(host);
|
||||
host.start();
|
||||
const stopPush = subscribePushStateChanges((change, changedClient) => {
|
||||
for (const [account, entry] of useAuthStore.getState().getAllConnectedClients()) {
|
||||
if (entry !== changedClient) continue;
|
||||
if (Object.values(change.changed).some((types) => !!types && "FileNode" in types)) host.remoteChanged(account);
|
||||
}
|
||||
});
|
||||
const stopPolicy = onDesktopPolicyChange(() => host.policyChanged());
|
||||
const online = () => host.online();
|
||||
window.addEventListener("online", online);
|
||||
return () => {
|
||||
stopPush();
|
||||
stopPolicy();
|
||||
window.removeEventListener("online", online);
|
||||
setRunningFilesSyncHost(null);
|
||||
host.stop();
|
||||
hostRef.current = null;
|
||||
store.reset();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const clients = isAuthenticated ? useAuthStore.getState().getAllConnectedClients() : new Map();
|
||||
const list: HostAccount[] = [];
|
||||
for (const [id, entry] of clients) {
|
||||
const account = accounts.find((a) => a.id === id);
|
||||
if (account && !account.isConnected) continue;
|
||||
list.push({ id, label: account?.email || account?.username || account?.label || id, client: entry });
|
||||
}
|
||||
host.setAccounts(list, isAuthenticated ? activeAccountId : null);
|
||||
}, [accounts, activeAccountId, isAuthenticated, revision, client]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import en from '@/locales/en/common.json';
|
||||
import type { FilesDriveState, FilesRoot } from '@/lib/desktop/types';
|
||||
import type { FilesSyncActivity, FilesSyncController, FilesSyncRootStatus } from '@/lib/files-sync/types';
|
||||
|
||||
// Files on this computer, the settings page: the drive, synced folders, the
|
||||
// activity list and the mass-delete question, against the store and a fake
|
||||
// controller (the engine is built elsewhere).
|
||||
|
||||
vi.unmock('next-intl');
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
|
||||
const { useFilesSyncStore } = await import('@/stores/files-sync-store');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { DesktopFilesSettings } = await import('../desktop-files-settings');
|
||||
|
||||
type Fake = { [K in keyof FilesSyncController]: ReturnType<typeof vi.fn> };
|
||||
|
||||
function fakeController(): Fake {
|
||||
return {
|
||||
addFolder: vi.fn(async () => ({ ok: true as const, value: folderRoot() })),
|
||||
removeRoot: vi.fn(async () => {}),
|
||||
setPaused: vi.fn(async () => {}),
|
||||
confirmMassDelete: vi.fn(async () => {}),
|
||||
setDriveEnabled: vi.fn(async () => ({ ok: true as const, value: null })),
|
||||
setDriveLetter: vi.fn(async () => ({ ok: true as const, value: { letter: 'Z', chosen: 'Z', locked: false, available: [] } })),
|
||||
coverage: vi.fn(() => null),
|
||||
showInFolder: vi.fn(async () => true),
|
||||
clearActivity: vi.fn(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
function folderRoot(patch: Partial<FilesRoot> = {}): FilesRoot {
|
||||
return {
|
||||
id: 'r1', kind: 'folder', account: 'acc', path: 'C:\\Users\\ann\\Documents\\Projects', remoteId: 'n-proj',
|
||||
remoteLabel: 'Projects', paused: false, createdAt: 1, ...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function drive(patch: Partial<FilesDriveState> = {}): FilesDriveState {
|
||||
return {
|
||||
supported: true, forcedOff: false, wanted: [],
|
||||
letter: { letter: null, chosen: null, locked: false, available: ['X', 'Z'] },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
let controller: ReturnType<typeof fakeController>;
|
||||
|
||||
function setup(patch: Record<string, unknown> = {}) {
|
||||
controller = fakeController();
|
||||
useFilesSyncStore.setState({ available: true, account: 'acc', controller: controller as unknown as FilesSyncController, ...patch });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
|
||||
<DesktopFilesSettings />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useFilesSyncStore.getState().reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
useFilesSyncStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('Files on this computer settings page', () => {
|
||||
it('draws nothing outside the desktop app or on an older shell', () => {
|
||||
useFilesSyncStore.setState({ available: false, roots: [folderRoot()], drive: drive() });
|
||||
const { container } = renderPage();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('has no drive section off Windows', () => {
|
||||
setup({ drive: null });
|
||||
renderPage();
|
||||
expect(screen.queryByRole('switch', { name: 'Show Files as a drive' })).toBeNull();
|
||||
expect(screen.getByRole('heading', { name: 'Synced folders' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns the drive on for the account from the click itself', () => {
|
||||
setup({ drive: drive() });
|
||||
renderPage();
|
||||
const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
// No drive, no letter.
|
||||
expect(screen.queryByText('Drive letter')).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
// Synchronously: the shell needs the click it came from.
|
||||
expect(controller.setDriveEnabled).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows the drive on, and turns it off', () => {
|
||||
setup({ drive: drive({ wanted: ['acc'] }) });
|
||||
renderPage();
|
||||
const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
fireEvent.click(toggle);
|
||||
expect(controller.setDriveEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('says the organisation turned the drive off and keeps the switch off', () => {
|
||||
setup({ drive: drive({ forcedOff: true, wanted: ['acc'] }) });
|
||||
renderPage();
|
||||
const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(screen.getByText(en.settings.org_locked)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no letter by default and the free letters, and sets the chosen one', () => {
|
||||
setup({ drive: drive({ wanted: ['acc'] }) });
|
||||
renderPage();
|
||||
const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement;
|
||||
expect(select.value).toBe('none');
|
||||
expect([...select.options].map((o) => o.textContent)).toEqual(['No letter', 'X:', 'Z:']);
|
||||
fireEvent.change(select, { target: { value: 'Z' } });
|
||||
expect(controller.setDriveLetter).toHaveBeenCalledWith('Z');
|
||||
fireEvent.change(select, { target: { value: 'none' } });
|
||||
expect(controller.setDriveLetter).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows a letter forced by policy, locked', () => {
|
||||
setup({ drive: drive({ wanted: ['acc'], letter: { letter: 'Q', chosen: 'X', locked: true, available: ['X'] } }) });
|
||||
renderPage();
|
||||
const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement;
|
||||
expect(select.value).toBe('Q');
|
||||
expect(select).toBeDisabled();
|
||||
const row = select.closest('[data-search-label]') as HTMLElement;
|
||||
expect(within(row).getByText(en.settings.org_locked)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists a synced folder with its Files folder and pauses and resumes it', () => {
|
||||
setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'idle', pending: 0, done: 0, lastSyncedAt: null } } });
|
||||
const { rerender } = renderPage();
|
||||
expect(screen.getByText('C:\\Users\\ann\\Documents\\Projects')).toBeInTheDocument();
|
||||
expect(screen.getByText('With Projects in Files')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause' }));
|
||||
expect(controller.setPaused).toHaveBeenCalledWith('r1', true);
|
||||
|
||||
act(() => useFilesSyncStore.setState({ roots: [folderRoot({ paused: true })], status: { r1: { rootId: 'r1', state: 'paused', pending: 0, done: 0, lastSyncedAt: null } } }));
|
||||
rerender(
|
||||
<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
|
||||
<DesktopFilesSettings />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume' }));
|
||||
expect(controller.setPaused).toHaveBeenLastCalledWith('r1', false);
|
||||
});
|
||||
|
||||
it('asks before removing a folder, saying its files stay', () => {
|
||||
setup({ roots: [folderRoot()] });
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove…' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(dialog).toHaveTextContent('The files on this computer stay where they are');
|
||||
expect(controller.removeRoot).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Stop syncing' }));
|
||||
expect(controller.removeRoot).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
|
||||
it('says how each folder is doing', () => {
|
||||
const at = Date.UTC(2026, 10, 4, 14, 5);
|
||||
vi.useFakeTimers({ now: at + 60_000, toFake: ['Date'] });
|
||||
const states: [string, Partial<FilesSyncRootStatus>][] = [
|
||||
['idle', { lastSyncedAt: at }],
|
||||
['syncing', { done: 3, pending: 7 }],
|
||||
['paused', {}],
|
||||
['offline', { pending: 2 }],
|
||||
['error', { errorKey: 'files_sync.errors.quota' }],
|
||||
['blocked', {}],
|
||||
['starting', {}],
|
||||
];
|
||||
const roots = states.map(([state]) => folderRoot({ id: state, path: `/home/ann/${state}` }));
|
||||
const status = Object.fromEntries(states.map(([state, extra]) => [state, { rootId: state, state, pending: 0, done: 0, lastSyncedAt: null, ...extra }]));
|
||||
setup({ roots, status });
|
||||
renderPage();
|
||||
const line = (id: string) => document.querySelector(`[data-files-sync-root="${id}"] [data-files-sync-state]`)!.textContent;
|
||||
expect(line('idle')).toMatch(/^Up to date\. Last synced .*2:05/);
|
||||
expect(line('syncing')).toBe('Syncing: 3 done, 7 to go');
|
||||
expect(line('paused')).toBe('Paused');
|
||||
expect(line('offline')).toBe('Offline. 2 changes wait for the server');
|
||||
expect(line('error')).toBe('Stopped: Your storage in Files is full');
|
||||
expect(line('blocked')).toBe('Turned off by your organisation');
|
||||
expect(line('starting')).toBe('Starting…');
|
||||
// A blocked folder can't be resumed, only removed.
|
||||
expect(within(document.querySelector('[data-files-sync-root="blocked"]') as HTMLElement).queryByRole('button', { name: 'Resume' })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows an unknown error key as a plain failure, never the key', () => {
|
||||
setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'error', pending: 0, done: 0, lastSyncedAt: null, errorKey: 'something_new' } } });
|
||||
renderPage();
|
||||
expect(screen.getByText('Stopped: Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a folder: picks the Files folder, then calls the controller from that click', async () => {
|
||||
const listAllFileNodes = vi.fn(async () => [
|
||||
{ id: 'n-proj', name: 'Projects', parentId: null, blobId: null },
|
||||
{ id: 'n-2026', name: '2026', parentId: 'n-proj', blobId: null },
|
||||
{ id: 'n-file', name: 'a.txt', parentId: 'n-proj', blobId: 'b1' },
|
||||
]);
|
||||
useAuthStore.setState({ client: { listAllFileNodes } as never });
|
||||
setup({ roots: [] });
|
||||
renderPage();
|
||||
expect(screen.getByText('No folders are synced yet.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add folder…' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Which folder in Files?' });
|
||||
// Only folders are offered.
|
||||
expect(within(dialog).queryByText('a.txt')).toBeNull();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Expand Projects' }));
|
||||
await waitFor(() => expect(within(dialog).getByText('2026')).toBeInTheDocument());
|
||||
fireEvent.click(within(dialog).getByText('2026'));
|
||||
expect(controller.addFolder).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' }));
|
||||
// In the same task as the click: nothing slow is awaited first.
|
||||
expect(controller.addFolder).toHaveBeenCalledWith('n-2026', 'Projects / 2026');
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
it('says why a folder could not be added, and nothing when the picker was closed', async () => {
|
||||
useAuthStore.setState({ client: { listAllFileNodes: vi.fn(async () => [{ id: 'n-proj', name: 'Projects', parentId: null, blobId: null }]) } as never });
|
||||
setup({ roots: [] });
|
||||
controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'overlap' });
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add folder…' }));
|
||||
let dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByText('Projects'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(en.files_sync.folders.add_failed.overlap);
|
||||
|
||||
controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'cancelled' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add folder…' }));
|
||||
dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByText('Projects'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' }));
|
||||
await waitFor(() => expect(controller.addFolder).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||
useAuthStore.setState({ client: null });
|
||||
});
|
||||
|
||||
it('hides adding folders when the organisation turned folder sync off', () => {
|
||||
setup({ folderSyncForcedOff: true, roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'blocked', pending: 0, done: 0, lastSyncedAt: null } } });
|
||||
renderPage();
|
||||
expect(screen.queryByRole('button', { name: 'Add folder…' })).toBeNull();
|
||||
expect(screen.getByTestId('files-sync-folders-locked')).toHaveTextContent(en.settings.org_locked);
|
||||
// The folder can still be removed; its files stay.
|
||||
expect(screen.getByRole('button', { name: 'Remove…' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists recent activity newest first, marks conflicts and errors, and clears it', () => {
|
||||
const activity: FilesSyncActivity[] = [
|
||||
{ id: 'a1', at: 1000, rootId: 'r1', kind: 'uploaded', direction: 'up', path: 'notes.txt' },
|
||||
{ id: 'a3', at: 3000, rootId: 'r1', kind: 'error', direction: 'up', path: 'big.iso', messageKey: 'files_sync.errors.quota' },
|
||||
{ id: 'a2', at: 2000, rootId: 'r1', kind: 'conflict', direction: null, path: 'plan.docx', detail: 'plan (conflict, PC, 2026-11-04).docx' },
|
||||
{ id: 'a4', at: 500, rootId: 'r1', kind: 'too-large', direction: null, path: 'video.mp4', detail: '1048576' },
|
||||
];
|
||||
setup({ roots: [folderRoot()], activity });
|
||||
renderPage();
|
||||
const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem');
|
||||
expect(rows.map((r) => r.getAttribute('data-activity-kind'))).toEqual(['error', 'conflict', 'uploaded', 'too-large']);
|
||||
expect(rows[0]).toHaveTextContent('big.iso couldn\'t be synced: Your storage in Files is full');
|
||||
expect(within(rows[0]).getByRole('img', { name: 'Error' })).toBeInTheDocument();
|
||||
expect(rows[1]).toHaveTextContent('plan.docx was changed in both places. The other version is kept as plan (conflict, PC, 2026-11-04).docx');
|
||||
expect(within(rows[1]).getByRole('img', { name: 'Conflict' })).toBeInTheDocument();
|
||||
expect(rows[2]).toHaveTextContent('Projects');
|
||||
expect(rows[2]).toHaveTextContent('Uploaded notes.txt');
|
||||
expect(within(rows[2]).getByRole('img', { name: 'To Files' })).toBeInTheDocument();
|
||||
expect(rows[3]).toHaveTextContent('video.mp4 is bigger than Files allows (1 MB)');
|
||||
expect(within(rows[3]).getByRole('img', { name: 'Error' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(controller.clearActivity).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a line for every kind of activity', () => {
|
||||
const kinds = ['uploaded', 'downloaded', 'folder-created', 'renamed', 'moved', 'deleted', 'trashed', 'conflict', 'renamed-for-disk', 'too-large', 'waiting', 'mass-delete', 'error'] as const;
|
||||
setup({ roots: [folderRoot()], activity: kinds.map((kind, i) => ({ id: kind, at: 10_000 - i, rootId: 'r1', kind, direction: null, path: 'x.txt', detail: 'y.txt' })) });
|
||||
renderPage();
|
||||
const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem');
|
||||
expect(rows).toHaveLength(kinds.length);
|
||||
for (const row of rows) expect(row.textContent).not.toMatch(/files_sync\.|\{/);
|
||||
});
|
||||
|
||||
it('says when nothing happened yet', () => {
|
||||
setup({ roots: [folderRoot()] });
|
||||
renderPage();
|
||||
expect(screen.getByText('Nothing has happened yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('asks about a mass delete in the folder row, with both answers', () => {
|
||||
setup({
|
||||
roots: [folderRoot(), folderRoot({ id: 'r2', path: '/home/ann/Photos', remoteLabel: 'Photos' })],
|
||||
status: {
|
||||
r1: { rootId: 'r1', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 120, side: 'local' } },
|
||||
r2: { rootId: 'r2', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 64, side: 'remote' } },
|
||||
},
|
||||
});
|
||||
renderPage();
|
||||
const first = document.querySelector('[data-files-sync-root="r1"]') as HTMLElement;
|
||||
expect(first).toHaveTextContent('120 items were deleted from Projects on this computer. Delete them in Files too?');
|
||||
const second = document.querySelector('[data-files-sync-root="r2"]') as HTMLElement;
|
||||
expect(second).toHaveTextContent('64 items were deleted from Photos in Files. Delete them on this computer too?');
|
||||
fireEvent.click(within(first).getByRole('button', { name: 'Delete them' }));
|
||||
expect(controller.confirmMassDelete).toHaveBeenCalledWith('r1', true);
|
||||
fireEvent.click(within(second).getByRole('button', { name: 'Bring them back' }));
|
||||
expect(controller.confirmMassDelete).toHaveBeenLastCalledWith('r2', false);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons');
|
||||
const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys');
|
||||
const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints');
|
||||
const { useFilesSyncStore } = await import('@/stores/files-sync-store');
|
||||
|
||||
/** A client that supports everything it is asked about. */
|
||||
const fullClient = new Proxy({}, {
|
||||
@@ -91,11 +92,25 @@ describe('Settings while the session restores', () => {
|
||||
expect(skeleton()).toBeNull();
|
||||
const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')]
|
||||
.map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length));
|
||||
// Recovery waits for a server probe this test doesn't answer.
|
||||
const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery');
|
||||
// Recovery waits for a server probe this test doesn't answer; Files on
|
||||
// this computer for the desktop app's sync engine.
|
||||
const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery' && tab !== 'files_sync');
|
||||
expect(shown).toEqual(drawn);
|
||||
});
|
||||
|
||||
it('lists Files on this computer after Files only while the desktop sync engine runs', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never });
|
||||
useFilesSyncStore.setState({ available: true });
|
||||
try {
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')]
|
||||
.map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length));
|
||||
expect(shown.slice(shown.indexOf('files'), shown.indexOf('files') + 2)).toEqual(['files', 'files_sync']);
|
||||
} finally {
|
||||
useFilesSyncStore.getState().reset();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the next load the pages this account has', () => {
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never });
|
||||
render(<SettingsApp linkSegments={[]} />);
|
||||
|
||||
@@ -36,6 +36,7 @@ const { useCalendarStore } = await import('@/stores/calendar-store');
|
||||
const { useContactStore } = await import('@/stores/contact-store');
|
||||
const { useEmailStore } = await import('@/stores/email-store');
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
const { useFilesSyncStore } = await import('@/stores/files-sync-store');
|
||||
const { TourProvider } = await import('@/components/tour/tour-provider');
|
||||
const { AdvancedSettingsProvider } = await import('../settings-section');
|
||||
const { tabSearchEntries, getByPath, withAppName, settingsTabLabelKey } = await import('@/lib/settings-search');
|
||||
@@ -60,6 +61,7 @@ const pages = {
|
||||
contacts: (await import('../contacts-settings')).ContactsSettings,
|
||||
contacts2: (await import('../address-book-management-settings')).AddressBookManagementSettings,
|
||||
files: (await import('../files-settings')).FilesSettingsComponent,
|
||||
files_sync: (await import('../desktop-files-settings')).DesktopFilesSettings,
|
||||
addons: (await import('../addons-settings')).AddonsSettings,
|
||||
backup: (await import('../about-data-settings')).AboutDataSettings,
|
||||
offline: (await import('@/components/offline/offline-settings')).OfflineSettings,
|
||||
@@ -76,7 +78,7 @@ const OVERRIDES: Record<string, () => unknown> = {
|
||||
getPrincipals: async () => [], listArchivedEmails: async () => ({ items: [], nextPosition: null }),
|
||||
};
|
||||
/** Pages with rows of the desktop app's own, or browser rows it hides. */
|
||||
const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup']);
|
||||
const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup', 'files_sync']);
|
||||
|
||||
/** The desktop app's bridge, answering as Windows does, with every switch off. */
|
||||
const DESKTOP_BRIDGE = {
|
||||
@@ -121,7 +123,7 @@ const TAB_PAGES: Record<string, string[]> = {
|
||||
accessibility: ['accessibility'],
|
||||
message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'],
|
||||
filters: ['filters'], organize: ['organize'], security: ['security'], sharing: ['sharing'], recovery: ['recovery'], calendar: ['calendar', 'calendar2'],
|
||||
contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'],
|
||||
contacts: ['contacts', 'contacts2'], files: ['files'], files_sync: ['files_sync'], addons: ['addons'], backup: ['backup'],
|
||||
offline: ['offline'], privacy: ['privacy'], about: ['about'],
|
||||
};
|
||||
|
||||
@@ -141,7 +143,15 @@ describe('settings search index', () => {
|
||||
const realMatchMedia = window.matchMedia;
|
||||
const variants: Record<string, () => void> = {
|
||||
base: () => {},
|
||||
desktop: () => { window.bulwarkDesktop = DESKTOP_BRIDGE as never; },
|
||||
desktop: () => {
|
||||
window.bulwarkDesktop = DESKTOP_BRIDGE as never;
|
||||
// Files on this computer: the engine runs, on Windows, with the drive on offer.
|
||||
useFilesSyncStore.setState({
|
||||
available: true,
|
||||
account: 'acc',
|
||||
drive: { supported: true, forcedOff: false, wanted: [], letter: { letter: null, chosen: null, locked: false, available: ['Z'] } },
|
||||
});
|
||||
},
|
||||
touch: () => {
|
||||
window.matchMedia = ((q: string) => ({ ...realMatchMedia(q), matches: q.includes('coarse') })) as typeof window.matchMedia;
|
||||
},
|
||||
@@ -160,6 +170,7 @@ describe('settings search index', () => {
|
||||
useAccountStore.setState(accountsInitial, true);
|
||||
window.matchMedia = realMatchMedia;
|
||||
window.bulwarkDesktop = undefined;
|
||||
useFilesSyncStore.getState().reset();
|
||||
apply();
|
||||
out[variant] = {};
|
||||
for (const [tab, Page] of Object.entries(pages)) {
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import {
|
||||
AlertCircle, AlertTriangle, ArrowDown, ArrowUp, FolderSync, HardDrive, HelpCircle, Loader2, Trash2, type AppIcon,
|
||||
} from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { FolderPickerDialog, type PickerFolder } from "@/components/files/folder-picker-dialog";
|
||||
import { FilesSyncStatusLine } from "@/components/files-sync/files-sync-status-line";
|
||||
import { MassDeleteQuestion, useInlineMassDeleteQuestions } from "@/components/files-sync/files-sync-prompts";
|
||||
import { filesSyncErrorKey, rootName, syncTimeOptions } from "@/components/files-sync/files-sync-text";
|
||||
import { fileNodeKind } from "@/lib/jmap/filenode-kind";
|
||||
import { findTrashFolder, trashSubtreeIds } from "@/lib/files-trash";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import type { FilesDriveState, FilesError, FilesRoot } from "@/lib/desktop/types";
|
||||
import type { FilesSyncActivity, FilesSyncActivityKind } from "@/lib/files-sync/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useFilesSyncStore } from "@/stores/files-sync-store";
|
||||
import { OrgLockedNote } from "./org-locked-note";
|
||||
import { SettingItem, SettingsSection, Select, ToggleSwitch } from "./settings-section";
|
||||
|
||||
/**
|
||||
* Settings > Files on this computer, in the desktop app only: the Files drive
|
||||
* (Windows), folders kept the same as a Files folder, and what happened lately.
|
||||
* Everything comes from stores/files-sync-store.ts and goes to the engine's
|
||||
* controller; the page never talks to the bridge.
|
||||
*/
|
||||
export function DesktopFilesSettings() {
|
||||
const available = useFilesSyncStore((s) => s.available);
|
||||
const drive = useFilesSyncStore((s) => s.drive);
|
||||
// The page asks about a mass delete in the folder's row; the app-wide dialog waits.
|
||||
useInlineMassDeleteQuestions();
|
||||
if (!available) return null;
|
||||
return (
|
||||
<div className="space-y-8" data-testid="files-sync-settings">
|
||||
{drive && <DriveSection drive={drive} />}
|
||||
<FoldersSection />
|
||||
<ActivitySection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What went wrong turning something on, in words. */
|
||||
function addErrorKey(error: FilesError): string | null {
|
||||
if (error === "cancelled") return null;
|
||||
if (error === "overlap" || error === "denied" || error === "policy") return `folders.add_failed.${error}`;
|
||||
return "folders.add_failed.failed";
|
||||
}
|
||||
|
||||
function DriveSection({ drive }: { drive: FilesDriveState }) {
|
||||
const t = useTranslations("files_sync");
|
||||
const account = useFilesSyncStore((s) => s.account);
|
||||
const roots = useFilesSyncStore((s) => s.roots);
|
||||
const status = useFilesSyncStore((s) => s.status);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const on = !drive.forcedOff && !!account && drive.wanted.includes(account);
|
||||
const driveRoot = roots.find((r) => r.kind === "drive" && r.account === account);
|
||||
const { letter } = drive;
|
||||
const letterValue = (letter.locked ? letter.letter : letter.chosen) ?? "none";
|
||||
const letters = [...new Set([...letter.available, letter.chosen, letter.letter].filter((l): l is string => !!l))].sort();
|
||||
|
||||
const toggle = (next: boolean) => {
|
||||
if (!controller) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
// Straight from the click: turning it on needs the click it came from.
|
||||
void controller.setDriveEnabled(next).then(
|
||||
(result) => { if (!result.ok) setError(addErrorKey(result.error)); },
|
||||
() => setError("folders.add_failed.failed"),
|
||||
).finally(() => setBusy(false));
|
||||
};
|
||||
const chooseLetter = (value: string) => {
|
||||
if (!controller) return;
|
||||
setError(null);
|
||||
void controller.setDriveLetter(value === "none" ? null : value).then(
|
||||
(result) => { if (!result.ok) setError(addErrorKey(result.error)); },
|
||||
() => setError("folders.add_failed.failed"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="drive" title={t("drive.title")}>
|
||||
<SettingItem
|
||||
label={t("drive.label")}
|
||||
description={drive.supported ? t("drive.description") : t("drive.unsupported")}
|
||||
locked={drive.forcedOff}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={on}
|
||||
disabled={!drive.supported || drive.forcedOff || !controller || !account || busy}
|
||||
testId="files-sync-drive-toggle"
|
||||
onChange={toggle}
|
||||
/>
|
||||
</SettingItem>
|
||||
{on && driveRoot && <FilesSyncStatusLine status={status[driveRoot.id]} paused={driveRoot.paused} />}
|
||||
{on && driveRoot && status[driveRoot.id]?.state === "confirm-delete" && status[driveRoot.id]?.massDelete && (
|
||||
<MassDeleteQuestion root={driveRoot} status={status[driveRoot.id]} />
|
||||
)}
|
||||
{(on || letter.locked) && drive.supported && (
|
||||
<SettingItem label={t("drive.letter_label")} description={t("drive.letter_description")} locked={letter.locked}>
|
||||
<Select
|
||||
value={letterValue}
|
||||
disabled={letter.locked || !controller}
|
||||
onChange={chooseLetter}
|
||||
options={[
|
||||
{ value: "none", label: t("drive.letter_none") },
|
||||
...letters.map((l) => ({ value: l, label: `${l}:` })),
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{t(error)}</p>}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** The folders of the user's own Files, for the picker: no trash, nothing shared. */
|
||||
async function loadFolders(): Promise<PickerFolder[]> {
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) throw new Error("not signed in");
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const inTrash = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
|
||||
return nodes
|
||||
.filter((n) => fileNodeKind(n) === "directory" && !n.discoverableOnly && !n.isShared && !inTrash.has(n.id) && !n.id.includes(":"))
|
||||
.map((n) => ({ id: n.id, name: n.name, parentId: n.parentId ?? null }));
|
||||
}
|
||||
|
||||
/** "Projects / 2026" for a folder, "My files" for the top. */
|
||||
function folderLabel(folders: PickerFolder[], id: string | null, top: string): string {
|
||||
if (id === null) return top;
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let cur = byId.get(id); cur && !seen.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
|
||||
seen.add(cur.id);
|
||||
names.unshift(cur.name);
|
||||
}
|
||||
return names.join(" / ") || top;
|
||||
}
|
||||
|
||||
function FoldersSection() {
|
||||
const t = useTranslations("files_sync");
|
||||
const tFiles = useTranslations("files");
|
||||
const account = useFilesSyncStore((s) => s.account);
|
||||
const roots = useFilesSyncStore((s) => s.roots);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const forcedOff = useFilesSyncStore((s) => s.folderSyncForcedOff);
|
||||
const [folders, setFolders] = useState<PickerFolder[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<FilesRoot | null>(null);
|
||||
|
||||
const folderRoots = roots.filter((r) => r.kind === "folder" && (!account || r.account === account));
|
||||
|
||||
const openPicker = () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
loadFolders().then(setFolders, () => setError("folders.load_failed")).finally(() => setLoading(false));
|
||||
};
|
||||
// Called from the picker's button: the OS folder picker needs that click, so
|
||||
// nothing is awaited before addFolder.
|
||||
const pick = (remoteId: string | null) => {
|
||||
if (!controller) return;
|
||||
const label = folderLabel(folders ?? [], remoteId, tFiles("my_files"));
|
||||
void controller.addFolder(remoteId, label).then(
|
||||
(result) => { if (!result.ok) setError(addErrorKey(result.error)); },
|
||||
() => setError("folders.add_failed.failed"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="folders" title={t("folders.title")} description={t("folders.description")}>
|
||||
{forcedOff && (
|
||||
<div className="space-y-1" data-testid="files-sync-folders-locked">
|
||||
<OrgLockedNote />
|
||||
<p className="text-sm text-muted-foreground">{t("folders.forced_off")}</p>
|
||||
</div>
|
||||
)}
|
||||
{folderRoots.length === 0 ? (
|
||||
!forcedOff && <p className="text-sm text-muted-foreground">{t("folders.empty")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{folderRoots.map((root) => (
|
||||
<FolderRow key={root.id} root={root} onRemove={() => setRemoving(root)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!forcedOff && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" className="gap-1.5" onClick={openPicker} disabled={loading || !controller} data-testid="files-sync-add-folder">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin motion-reduce:animate-none" /> : <FolderSync className="w-4 h-4" />}
|
||||
{loading ? t("folders.loading") : t("folders.add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{t(error)}</p>}
|
||||
{folders && (
|
||||
<FolderPickerDialog
|
||||
isOpen
|
||||
onClose={() => setFolders(null)}
|
||||
onPick={pick}
|
||||
title={t("folders.pick_title")}
|
||||
confirmText={t("folders.pick_confirm")}
|
||||
folders={folders}
|
||||
// Nothing is "where it is now": every folder, My files too, can be picked.
|
||||
currentId="#none"
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
isOpen={!!removing}
|
||||
onClose={() => setRemoving(null)}
|
||||
onConfirm={() => { if (removing && controller) void controller.removeRoot(removing.id).catch(() => {}); }}
|
||||
title={t("folders.remove_title")}
|
||||
message={t("folders.remove_message")}
|
||||
confirmText={t("folders.remove_confirm")}
|
||||
variant="destructive"
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({ root, onRemove }: { root: FilesRoot; onRemove: () => void }) {
|
||||
const t = useTranslations("files_sync");
|
||||
const status = useFilesSyncStore((s) => s.status[root.id]);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const paused = root.paused || status?.state === "paused";
|
||||
const blocked = status?.state === "blocked";
|
||||
return (
|
||||
<li className="p-3" data-files-sync-root={root.id}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
<FolderSync className="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground break-all" dir="auto">{root.path}</p>
|
||||
<p className="text-xs text-muted-foreground break-words">{t("folders.paired_with", { folder: root.remoteLabel })}</p>
|
||||
<FilesSyncStatusLine status={status} paused={root.paused} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{!blocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!controller}
|
||||
onClick={() => { void controller?.setPaused(root.id, !paused).catch(() => {}); }}
|
||||
>
|
||||
{paused ? t("folders.resume") : t("folders.pause")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={onRemove}>{t("folders.remove")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{status?.state === "confirm-delete" && status.massDelete && <MassDeleteQuestion root={root} status={status} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const ERROR_KINDS: ReadonlySet<FilesSyncActivityKind> = new Set(["error", "too-large"]);
|
||||
|
||||
function ActivityMark({ item }: { item: FilesSyncActivity }) {
|
||||
const t = useTranslations("files_sync");
|
||||
let mark: { icon: AppIcon; label: string; className: string } | null = null;
|
||||
if (ERROR_KINDS.has(item.kind)) mark = { icon: AlertCircle, label: t("activity.mark_error"), className: "text-destructive" };
|
||||
else if (item.kind === "conflict") mark = { icon: AlertTriangle, label: t("activity.mark_conflict"), className: "text-amber-600 dark:text-amber-400" };
|
||||
else if (item.kind === "mass-delete") mark = { icon: HelpCircle, label: t("activity.mark_question"), className: "text-amber-600 dark:text-amber-400" };
|
||||
else if (item.direction === "up") mark = { icon: ArrowUp, label: t("activity.to_files"), className: "text-muted-foreground" };
|
||||
else if (item.direction === "down") mark = { icon: ArrowDown, label: t("activity.to_computer"), className: "text-muted-foreground" };
|
||||
if (!mark) return <span className="w-3.5 shrink-0" aria-hidden="true" />;
|
||||
const Icon = mark.icon;
|
||||
return (
|
||||
<span role="img" aria-label={mark.label} title={mark.label} className="inline-flex shrink-0 mt-0.5">
|
||||
<Icon className={cn("w-3.5 h-3.5", mark.className)} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection() {
|
||||
const t = useTranslations("files_sync");
|
||||
const format = useFormatter();
|
||||
const activity = useFilesSyncStore((s) => s.activity);
|
||||
const roots = useFilesSyncStore((s) => s.roots);
|
||||
const controller = useFilesSyncStore((s) => s.controller);
|
||||
const items = useMemo(() => [...activity].sort((a, b) => b.at - a.at), [activity]);
|
||||
const rootById = useMemo(() => new Map(roots.map((r) => [r.id, r])), [roots]);
|
||||
|
||||
const message = (item: FilesSyncActivity) => {
|
||||
const detail = item.kind === "too-large" && item.detail && /^\d+$/.test(item.detail)
|
||||
? formatFileSize(Number(item.detail))
|
||||
: item.detail ?? "";
|
||||
const text = t(`activity.${item.kind}`, { path: item.path, detail });
|
||||
return (item.kind === "error" || item.kind === "waiting") && item.messageKey
|
||||
? `${text}: ${t(filesSyncErrorKey(item.messageKey))}`
|
||||
: text;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="activity" title={t("activity.title")}>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("activity.empty")}</p>
|
||||
) : (
|
||||
<ul className="max-h-96 overflow-y-auto divide-y divide-border/60 rounded-md border border-border" data-testid="files-sync-activity">
|
||||
{items.map((item) => {
|
||||
const root = rootById.get(item.rootId);
|
||||
const marked = ERROR_KINDS.has(item.kind) || item.kind === "conflict" || item.kind === "mass-delete";
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn("flex items-start gap-2 px-3 py-2 text-sm", ERROR_KINDS.has(item.kind) && "bg-destructive/5", item.kind === "conflict" && "bg-amber-500/5")}
|
||||
data-activity-kind={item.kind}
|
||||
>
|
||||
<ActivityMark item={item} />
|
||||
<time dateTime={new Date(item.at).toISOString()} className="text-xs text-muted-foreground tabular-nums shrink-0 mt-0.5">
|
||||
{format.dateTime(new Date(item.at), syncTimeOptions(item.at))}
|
||||
</time>
|
||||
{root && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground shrink-0 max-w-[30%] mt-0.5">
|
||||
{root.kind === "drive" ? <HardDrive className="w-3 h-3 shrink-0" aria-hidden="true" /> : <FolderSync className="w-3 h-3 shrink-0" aria-hidden="true" />}
|
||||
<span className="truncate">{rootName(root, t("drive.root_name"))}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1 break-words", marked && "font-medium")} dir="auto">{message(item)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={items.length === 0 || !controller}
|
||||
onClick={() => controller?.clearActivity()}
|
||||
data-testid="files-sync-clear-activity"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />{t("activity.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,8 @@ import { HelpLinks } from '@/components/layout/help-links';
|
||||
import { OrganizeSettings } from '@/components/settings/organize-settings';
|
||||
import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DesktopFilesSettings } from '@/components/settings/desktop-files-settings';
|
||||
import { useFilesSyncStore } from '@/stores/files-sync-store';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { AddonsSettings } from '@/components/settings/addons-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
@@ -686,6 +688,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
const supportsCalendar = client?.supportsCalendars() ?? false;
|
||||
const supportsSieve = client?.supportsSieve() ?? false;
|
||||
const supportsFiles = client?.supportsFiles() ?? false;
|
||||
// Files on this computer: the desktop app's sync engine runs in this window.
|
||||
const filesSyncAvailable = useFilesSyncStore((s) => s.available);
|
||||
// Delegates (Settings > Sharing) needs someone to share with and something to share.
|
||||
const supportsSharing = !delegationHidden && !!client?.getShareTargets && (client?.supportsPrincipals() ?? false)
|
||||
&& ((client?.supportsMailboxSharing?.() ?? false)
|
||||
@@ -718,6 +722,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
|
||||
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
|
||||
...(filesSyncAvailable && supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files_sync' as Tab, label: tRoot(settingsTabLabelKey('files_sync')), icon: tabIcons.files_sync, group: 'apps' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') || isFeatureEnabled('pluginsEnabled') ? [{ id: 'addons' as Tab, label: t('tabs.addons'), icon: tabIcons.addons, group: 'apps' as TabGroup }] : []),
|
||||
// Plugin-contributed pages nest under Add-ons: a page of its own for each
|
||||
// plugin that offers one, and an entry for each plugin that offers only a
|
||||
@@ -926,6 +931,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
: <><ContactsSettings /><div className="mt-8"><AddressBookManagementSettings /></div></>
|
||||
)}
|
||||
{effectiveActiveTab === 'files' && <FilesSettingsComponent />}
|
||||
{effectiveActiveTab === 'files_sync' && <DesktopFilesSettings />}
|
||||
{effectiveActiveTab === 'addons' && <AddonsSettings />}
|
||||
{effectiveActiveTab === 'privacy' && <PrivacySettings />}
|
||||
{effectiveActiveTab === 'offline' && <OfflineSettings />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Calendar,
|
||||
Filter,
|
||||
FolderOpen,
|
||||
FolderSync,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
Bell,
|
||||
@@ -48,6 +49,7 @@ export const SETTINGS_TAB_ICONS: Record<SettingsSearchTab, AppIcon> = {
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
files: HardDrive,
|
||||
files_sync: FolderSync,
|
||||
addons: Puzzle,
|
||||
offline: CloudOff,
|
||||
privacy: EyeOff,
|
||||
@@ -66,6 +68,6 @@ export type SettingsTabGroup = 'top' | 'mail' | 'apps' | 'bottom';
|
||||
export const SETTINGS_NAV_GROUPS: readonly { group: SettingsTabGroup; tabs: readonly SettingsSearchTab[] }[] = [
|
||||
{ group: 'top', tabs: ['account', 'security', 'privacy', 'sharing', 'notifications', 'general', 'offline', 'appearance', 'accessibility'] },
|
||||
{ group: 'mail', tabs: ['message_list', 'reading', 'composing', 'organize', 'filters', 'vacation', 'recovery'] },
|
||||
{ group: 'apps', tabs: ['calendar', 'contacts', 'files', 'addons'] },
|
||||
{ group: 'apps', tabs: ['calendar', 'contacts', 'files', 'files_sync', 'addons'] },
|
||||
{ group: 'bottom', tabs: ['backup', 'about'] },
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useId, useRef } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Star, Plus, Square, CheckSquare } from '@/components/icons';
|
||||
import { Star, Plus, Square, CheckSquare, Braces } from '@/components/icons';
|
||||
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
|
||||
import { useContextMenu } from '@/hooks/use-context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -338,6 +338,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
|
||||
{BUILT_IN_PLACEHOLDERS.map((p) => (
|
||||
<ContextMenuItem
|
||||
key={p}
|
||||
icon={Braces}
|
||||
label={`{{${p}}}`}
|
||||
hint={t(`placeholders.${p}`)}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { ConfirmDialog } from '../confirm-dialog';
|
||||
import type { BulwarkDesktopBridge, DesktopDialogWindowAnswer, DesktopDialogWindowRequest } from '@/lib/desktop/types';
|
||||
|
||||
// In the desktop app the app's confirm (Empty trash, Delete forever, Sign out, …) is a
|
||||
// window of its own; without the shell's dialogs.show it stays in the page.
|
||||
|
||||
type Show = (request: DesktopDialogWindowRequest) => Promise<DesktopDialogWindowAnswer>;
|
||||
|
||||
function desktop(show: Show) {
|
||||
const cancel = vi.fn();
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm: vi.fn(), show: vi.fn(show), cancel } } as unknown as BulwarkDesktopBridge;
|
||||
return { show: window.bulwarkDesktop!.dialogs!.show as unknown as ReturnType<typeof vi.fn<Show>>, cancel };
|
||||
}
|
||||
|
||||
/** A show that waits until the test answers. */
|
||||
function pending() {
|
||||
let answer: (value: DesktopDialogWindowAnswer) => void = () => {};
|
||||
const show: Show = () => new Promise((resolve) => (answer = resolve));
|
||||
return { show, answer: (value: DesktopDialogWindowAnswer) => answer(value) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const props = { title: 'Empty trash?', message: 'All messages in Trash will be deleted.', confirmText: 'Empty trash', variant: 'destructive' as const };
|
||||
|
||||
describe('ConfirmDialog in the desktop app', () => {
|
||||
it('asks in a window, draws nothing in the page, and confirms with the window\'s button', async () => {
|
||||
const gate = pending();
|
||||
const { show } = desktop(gate.show);
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} {...props} />);
|
||||
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({
|
||||
title: 'Empty trash?',
|
||||
message: [{ text: 'All messages in Trash will be deleted.' }],
|
||||
icon: 'warning',
|
||||
buttons: [{ label: 'cancel', variant: 'secondary' }, { label: 'Empty trash', variant: 'destructive' }],
|
||||
// Something that can't be undone: Enter is Cancel's.
|
||||
defaultIndex: 0,
|
||||
});
|
||||
|
||||
await act(async () => gate.answer({ button: 1 }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Cancel, Escape or a closed window only close it', async () => {
|
||||
for (const answer of [{ button: 0 }, null]) {
|
||||
const gate = pending();
|
||||
desktop(gate.show);
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const view = render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} {...props} variant="default" />);
|
||||
await act(async () => gate.answer(answer));
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('a plain question says yes on Enter', () => {
|
||||
const { show } = desktop(pending().show);
|
||||
render(<ConfirmDialog isOpen onClose={vi.fn()} onConfirm={vi.fn()} title="Sign out?" message="You can sign in again." />);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({ defaultIndex: 1, buttons: [{ variant: 'secondary' }, { label: 'confirm', variant: 'primary' }] });
|
||||
expect(show.mock.calls[0][0].icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the window back when the page closes the question', () => {
|
||||
const { cancel } = desktop(pending().show);
|
||||
const view = render(<ConfirmDialog isOpen onClose={vi.fn()} onConfirm={vi.fn()} {...props} />);
|
||||
view.rerender(<ConfirmDialog isOpen={false} onClose={vi.fn()} onConfirm={vi.fn()} {...props} />);
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(cancel.mock.calls[0][0]).toMatch(/^dialog-/);
|
||||
});
|
||||
|
||||
it('stays in the page with rich details, without the bridge, or when the shell refuses', async () => {
|
||||
const { show } = desktop(pending().show);
|
||||
const view = render(<ConfirmDialog isOpen onClose={vi.fn()} onConfirm={vi.fn()} {...props} details={<ul><li>a@example.org</li></ul>} />);
|
||||
expect(screen.getByRole('alertdialog', { name: 'Empty trash?' })).toBeTruthy();
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm: vi.fn() } } as unknown as BulwarkDesktopBridge;
|
||||
const older = render(<ConfirmDialog isOpen onClose={vi.fn()} onConfirm={vi.fn()} {...props} />);
|
||||
expect(screen.getByRole('alertdialog', { name: 'Empty trash?' })).toBeTruthy();
|
||||
older.unmount();
|
||||
|
||||
desktop(async () => {
|
||||
throw new Error('Not a question');
|
||||
});
|
||||
render(<ConfirmDialog isOpen onClose={vi.fn()} onConfirm={vi.fn()} {...props} />);
|
||||
expect(await screen.findByRole('alertdialog', { name: 'Empty trash?' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu';
|
||||
import { useContextMenu } from '@/hooks/use-context-menu';
|
||||
import { placeMenu } from '@/lib/menu-placement';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
|
||||
// Every menu opens next to what opened it: under a button, lined up with its
|
||||
// edge, or at the pointer, and towards the other side only where there's no
|
||||
@@ -152,34 +151,6 @@ describe('placeMenu', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a native menu from a button', () => {
|
||||
it('lines its right edge up with an end-aligned button, by the native menu\'s width, not the hidden copy\'s', async () => {
|
||||
const popup = vi.fn(async () => null);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as unknown as BulwarkDesktopBridge;
|
||||
// The hidden copy holds every submenu open, so it is far wider than the menu the OS draws.
|
||||
MENU.width = 700;
|
||||
try {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 468, top: 40, right: 500, bottom: 72, align: 'end' } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="Forward as attachment" onClick={() => {}} />
|
||||
<ContextMenuSubMenu label="Rules">
|
||||
<ContextMenuItem label="Always move mail from this sender to a folder of its own" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
<ContextMenuItem label="Print" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
const at = (popup.mock.calls[0] as unknown[])[1] as { x: number; y: number };
|
||||
expect(at.y).toBe(76);
|
||||
// Under the button: its left edge somewhere left of the button, not at the window's edge.
|
||||
expect(at.x).toBeGreaterThan(200);
|
||||
expect(at.x).toBeLessThan(468);
|
||||
} finally {
|
||||
MENU.width = 220;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a menu from a right-click on a row', () => {
|
||||
function Row() {
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<string>();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuCheckItem, ContextMenuItem } from '../context-menu';
|
||||
|
||||
// One right-click menu everywhere: rows with an icon column, a tick for what's on,
|
||||
// red only on a destructive row's icon (and the row under the pointer), and letters
|
||||
// that jump to a row.
|
||||
|
||||
const Icon = ({ className }: { className?: string }) => <svg data-testid="icon" className={className} />;
|
||||
|
||||
function open(children: React.ReactNode) {
|
||||
return render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={vi.fn()}>{children}</ContextMenu>);
|
||||
}
|
||||
|
||||
describe('a destructive row', () => {
|
||||
it('has the colour on its icon, not on its words', () => {
|
||||
open(<ContextMenuItem icon={Icon} label="Delete" destructive onClick={vi.fn()} testId="del" />);
|
||||
const row = screen.getByTestId('del');
|
||||
expect(row.querySelector('[data-testid="icon"]')!.getAttribute('class')).toContain('text-destructive');
|
||||
// Only under the pointer or the focus.
|
||||
expect(row.className.split(/\s+/)).not.toContain('text-destructive');
|
||||
expect(row.className).toContain('hover:text-destructive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a check row', () => {
|
||||
it('ticks in the icon column and says so to a screen reader', () => {
|
||||
open(
|
||||
<>
|
||||
<ContextMenuCheckItem label="Newest first" kind="radio" checked onClick={vi.fn()} />
|
||||
<ContextMenuCheckItem label="Oldest first" kind="radio" checked={false} onClick={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
const [on, off] = screen.getAllByRole('menuitemradio');
|
||||
expect(on).toHaveAttribute('aria-checked', 'true');
|
||||
expect(off).toHaveAttribute('aria-checked', 'false');
|
||||
expect(on.querySelector('svg')).not.toBeNull();
|
||||
expect(off.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('with an icon keeps the icon in the column and the tick at the end', () => {
|
||||
open(<ContextMenuCheckItem icon={Icon} label="Dark" kind="radio" checked onClick={vi.fn()} />);
|
||||
const row = screen.getByRole('menuitemradio');
|
||||
const svgs = row.querySelectorAll('svg');
|
||||
expect(svgs[0].getAttribute('data-testid')).toBe('icon');
|
||||
expect(svgs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type-ahead', () => {
|
||||
it('a letter goes to the next row that starts with it, and again to the one after', () => {
|
||||
open(
|
||||
<>
|
||||
<ContextMenuItem icon={Icon} label="Reply" onClick={vi.fn()} />
|
||||
<ContextMenuItem icon={Icon} label="Archive" onClick={vi.fn()} />
|
||||
<ContextMenuItem icon={Icon} label="Report spam" onClick={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
const rows = screen.getAllByRole('menuitem');
|
||||
rows[1].focus();
|
||||
fireEvent.keyDown(rows[1], { key: 'r' });
|
||||
expect(document.activeElement).toBe(rows[2]);
|
||||
fireEvent.keyDown(rows[2], { key: 'r' });
|
||||
expect(document.activeElement).toBe(rows[0]);
|
||||
});
|
||||
|
||||
it('several letters typed quickly make a word', () => {
|
||||
open(
|
||||
<>
|
||||
<ContextMenuItem icon={Icon} label="Reply" onClick={vi.fn()} />
|
||||
<ContextMenuItem icon={Icon} label="Report spam" onClick={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
const rows = screen.getAllByRole('menuitem');
|
||||
rows[0].focus();
|
||||
fireEvent.keyDown(rows[0], { key: 'r' });
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'e' });
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'p' });
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'o' });
|
||||
expect(document.activeElement).toBe(rows[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu';
|
||||
|
||||
// A menu row's key sits apart from its label, as a keycap, in the app's language as
|
||||
// Windows writes it. The desktop app shows these same rows (it used to read them into a
|
||||
// native menu, which wrote "Umschalttaste" from Chromium's German strings).
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.lang = '';
|
||||
});
|
||||
|
||||
function row(shortcut: string, lang = 'en') {
|
||||
document.documentElement.lang = lang;
|
||||
render(<div role="menu"><ContextMenuItem label="Reply all" shortcut={shortcut} onClick={() => {}} /></div>);
|
||||
const item = screen.getByRole('menuitem');
|
||||
return {
|
||||
label: item.querySelector('[data-menu-label]')?.textContent,
|
||||
key: item.querySelector('[data-menu-shortcut]')?.textContent,
|
||||
};
|
||||
}
|
||||
|
||||
describe('a menu row\'s key', () => {
|
||||
it('is not part of the label', () => {
|
||||
expect(row('Shift + R')).toEqual({ label: 'Reply all', key: 'Shift + R' });
|
||||
});
|
||||
|
||||
it('says Umschalt and Entf in German, as Windows does, never Umschalttaste', () => {
|
||||
expect(row('Shift + R', 'de').key).toBe('Umschalt + R');
|
||||
document.body.innerHTML = '';
|
||||
expect(row('Del', 'de').key).toBe('Entf');
|
||||
});
|
||||
|
||||
it('says Maj in French', () => {
|
||||
expect(row('Shift + I', 'fr').key).toBe('Maj + I');
|
||||
});
|
||||
|
||||
it('keeps a key that is already in the app\'s words', () => {
|
||||
expect(row('Strg + Umschalt + Eingabe', 'de').key).toBe('Strg + Umschalt + Eingabe');
|
||||
});
|
||||
});
|
||||
|
||||
// Menu rows show their keys through MenuShortcut (or ContextMenuItem's shortcut),
|
||||
// which puts them apart from the label in the app's words. A raw <kbd> in a file
|
||||
// with menu rows is how "DayD" happened.
|
||||
describe('menu rows show keys through MenuShortcut', () => {
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
it('no raw <kbd> next to role="menuitem…" rows', () => {
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.tsx')) files.push(full);
|
||||
}
|
||||
};
|
||||
walk(path.join(root, 'components'));
|
||||
walk(path.join(root, 'app'));
|
||||
const offenders = files.filter((file) => {
|
||||
if (file.endsWith(path.join('ui', 'context-menu.tsx'))) return false;
|
||||
const source = readFileSync(file, 'utf8');
|
||||
return /role="menuitem/.test(source) && /<kbd[\s>]/.test(source);
|
||||
});
|
||||
expect(offenders.map((file) => path.relative(root, file))).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// "Umschalttaste" is Chromium's word, not Windows': it must not appear in what the
|
||||
// app writes itself.
|
||||
describe('no Umschalttaste', () => {
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
it('in any locale file or source file', () => {
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (/\.(tsx?|json)$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
};
|
||||
for (const dir of ['components', 'app', 'lib', 'hooks', 'locales']) walk(path.join(root, dir));
|
||||
const offenders = files.filter((file) => /Umschalttaste/i.test(readFileSync(file, 'utf8')));
|
||||
expect(offenders.map((file) => path.relative(root, file))).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Every right-click, ⋮ and overflow menu is the shared ContextMenu
|
||||
// (components/ui/context-menu.tsx): one look, one keyboard model, one placement, and
|
||||
// the same in the browser and the desktop app. A component that draws a role="menu"
|
||||
// of its own is one more menu style. The few that aren't a list of actions are named
|
||||
// here with the reason.
|
||||
const ALLOWED: Record<string, string> = {
|
||||
'components/ui/context-menu.tsx': 'the shared menu itself',
|
||||
'components/layout/window-menu-bar.tsx': "the desktop app's menu bar: the shell's menus, with a menu bar's keys; it draws its rows in the shared menu's look",
|
||||
'components/layout/account-switcher.tsx': 'account cards with avatars and storage, not a list of actions',
|
||||
'components/layout/mv-top-bar.tsx': "Mountain View's app launcher: a grid of apps, as Gmail has it",
|
||||
'components/email/email-viewer.tsx': "the phone's More sheet, which slides in and has sub-views",
|
||||
'components/email/rich-text-editor.tsx': "the editor toolbar's pickers (font, size, colour)",
|
||||
'components/email/move-to-dialog.tsx': 'a folder list inside the Move dialog',
|
||||
'components/files/import-eml-dialog.tsx': 'a folder list inside the import dialog',
|
||||
};
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..');
|
||||
|
||||
function sources(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) sources(full, out);
|
||||
else if (entry.name.endsWith('.tsx')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = () => [...sources(path.join(ROOT, 'components')), ...sources(path.join(ROOT, 'app'))];
|
||||
const relative = (full: string) => path.relative(ROOT, full).split(path.sep).join('/');
|
||||
|
||||
// role="menu" as an attribute, or role: "menu" in props; not a selector like '[role="menu"]'.
|
||||
const MENU_ROOT = /(?<!\[)role=(?:"menu"|\{"menu"\}|'menu')|\brole:\s*["']menu["']/;
|
||||
|
||||
// A menu row and its props, up to the first ">" that isn't an arrow's.
|
||||
const MENU_ROW = /<(ContextMenuItem|ContextMenuSubMenu)\b((?:=>|[^>])*)>/g;
|
||||
|
||||
describe('one menu component', () => {
|
||||
it('no component draws a menu of its own', () => {
|
||||
const offenders = all()
|
||||
.map(relative)
|
||||
.filter((file) => !(file in ALLOWED))
|
||||
.filter((file) => MENU_ROOT.test(readFileSync(path.join(ROOT, file), 'utf8')));
|
||||
expect(offenders).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('the list of exceptions holds only files that still need it', () => {
|
||||
const stale = Object.keys(ALLOWED).filter((file) => {
|
||||
try {
|
||||
return !MENU_ROOT.test(readFileSync(path.join(ROOT, file), 'utf8'));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it('every row of it has an icon', () => {
|
||||
const missing: string[] = [];
|
||||
for (const full of all()) {
|
||||
const source = readFileSync(full, 'utf8');
|
||||
for (const match of source.matchAll(MENU_ROW)) {
|
||||
const attrs = match[2];
|
||||
if (attrs.includes('icon=') || attrs.includes('{...')) continue;
|
||||
const line = source.slice(0, match.index).split(/\r?\n/).length;
|
||||
missing.push(`${relative(full)}:${line}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('a row that deletes or clears is drawn as destructive', () => {
|
||||
const plain: string[] = [];
|
||||
for (const full of all()) {
|
||||
const source = readFileSync(full, 'utf8');
|
||||
for (const match of source.matchAll(MENU_ROW)) {
|
||||
const attrs = match[2];
|
||||
if (!/icon=\{(Trash2|Eraser)\}/.test(attrs) || attrs.includes('destructive')) continue;
|
||||
plain.push(`${relative(full)}:${source.slice(0, match.index).split(/\r?\n/).length}`);
|
||||
}
|
||||
}
|
||||
expect(plain).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { askInWindow, dialogWindowsAvailable } from "@/lib/desktop/dialog-window";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { AlertTriangle } from "@/components/icons";
|
||||
@@ -19,6 +21,82 @@ interface ConfirmDialogProps {
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
const noChanges = () => () => {};
|
||||
|
||||
interface WindowConfirm {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
variant: "default" | "destructive";
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks in a window of the app's own while `isOpen`, where the desktop shell can
|
||||
* (lib/desktop/dialog-window.ts). True while it does, so the caller draws nothing; false
|
||||
* in the browser, on an older shell, or once the shell refused, so the caller draws the
|
||||
* in-page dialog. Closing it from the page (isOpen false, unmount) takes the window back.
|
||||
*/
|
||||
export function useConfirmInWindow({ isOpen, title, message, confirmText, cancelText, variant, onConfirm, onClose }: WindowConfirm): boolean {
|
||||
const [refused, setRefused] = useState(false);
|
||||
const handlers = useRef({ onConfirm, onClose });
|
||||
useEffect(() => {
|
||||
handlers.current = { onConfirm, onClose };
|
||||
});
|
||||
const available = useSyncExternalStore(noChanges, dialogWindowsAvailable, () => false);
|
||||
const asking = isOpen && !refused && available;
|
||||
|
||||
useEffect(() => {
|
||||
if (!asking) return;
|
||||
const destructive = variant === "destructive";
|
||||
const asked = askInWindow({
|
||||
title,
|
||||
message,
|
||||
...(destructive ? { icon: "warning" as const } : {}),
|
||||
buttons: [
|
||||
{ label: cancelText, variant: "secondary" },
|
||||
{ label: confirmText, variant: destructive ? "destructive" : "primary" },
|
||||
],
|
||||
// Enter says yes, except to something that can't be undone: there the focus starts on Cancel.
|
||||
defaultIndex: destructive ? 0 : 1,
|
||||
});
|
||||
if (!asked) return;
|
||||
let live = true;
|
||||
void asked.answer.then((answer) => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
if (answer === "failed") {
|
||||
setRefused(true);
|
||||
return;
|
||||
}
|
||||
if (answer && "button" in answer && answer.button === 1) {
|
||||
try {
|
||||
handlers.current.onConfirm();
|
||||
} finally {
|
||||
handlers.current.onClose();
|
||||
}
|
||||
} else {
|
||||
handlers.current.onClose();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
asked.cancel();
|
||||
};
|
||||
}, [asking, title, message, confirmText, cancelText, variant]);
|
||||
|
||||
// The next question tries the window again.
|
||||
useEffect(() => {
|
||||
if (!isOpen) setRefused(false);
|
||||
}, [isOpen]);
|
||||
|
||||
return asking;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -35,6 +113,19 @@ export function ConfirmDialog({
|
||||
const resolvedConfirmText = confirmText || t("confirm");
|
||||
const resolvedCancelText = cancelText || t("cancel");
|
||||
|
||||
// In the desktop app a plain question (no rich details) gets a window of its own.
|
||||
const inWindow = useConfirmInWindow({
|
||||
isOpen: isOpen && details == null,
|
||||
title,
|
||||
message,
|
||||
confirmText: resolvedConfirmText,
|
||||
cancelText: resolvedCancelText,
|
||||
variant,
|
||||
onConfirm,
|
||||
onClose,
|
||||
});
|
||||
if (inWindow) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
|
||||
+128
-79
@@ -16,11 +16,9 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { estimateNativeMenuWidth, nativeMenuPoint, nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
|
||||
import { Check, ChevronRight } from "@/components/icons";
|
||||
import { placeMenu } from "@/lib/menu-placement";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -63,6 +61,10 @@ interface ContextMenuProps {
|
||||
const VIEWPORT_MARGIN = 10;
|
||||
const ANCHOR_GAP = 4;
|
||||
const HOVER_CLOSE_DELAY = 150;
|
||||
/** Letters typed within this many ms make one type-ahead word. */
|
||||
const TYPE_AHEAD_MS = 700;
|
||||
/** Every menu and submenu is at most this wide; longer labels wrap. */
|
||||
const MENU_MAX_WIDTH = `min(22rem, calc(100vw - ${2 * 10}px))`;
|
||||
|
||||
// ─── Which menu is open ────────────────────────────────────────
|
||||
|
||||
@@ -133,6 +135,29 @@ export function menuLevelItems(level: HTMLElement): HTMLElement[] {
|
||||
return all.filter((el) => !all.some((other) => other !== el && other.contains(el)) && isUsable(level, el));
|
||||
}
|
||||
|
||||
/** A row's words for type-ahead: its label, else its text. */
|
||||
function itemText(el: HTMLElement): string {
|
||||
return (el.querySelector<HTMLElement>("[data-menu-label]")?.textContent ?? el.textContent ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-ahead: the next row after `from` whose words start with `typed`, going round.
|
||||
* A repeated single letter steps through the rows that start with it.
|
||||
*/
|
||||
export function typeAheadTarget(items: HTMLElement[], from: HTMLElement | null, typed: string): HTMLElement | undefined {
|
||||
if (!typed || items.length === 0) return undefined;
|
||||
const start = from ? items.indexOf(from) : -1;
|
||||
const repeated = typed.length > 1 && [...typed].every((c) => c === typed[0]);
|
||||
const prefix = repeated ? typed[0] : typed;
|
||||
// Typing on (more letters): the current row may still match; one letter moves on.
|
||||
const offset = typed.length > 1 && !repeated ? 0 : 1;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[(start + offset + i + items.length) % items.length];
|
||||
if (itemText(item).startsWith(prefix)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Single-line text fields keep ←/→/Home/End; ↑/↓ still move through the menu. */
|
||||
function isSingleLineText(el: Element): boolean {
|
||||
return el instanceof HTMLInputElement && /^(text|search|email|url|tel|password|)$/.test(el.type);
|
||||
@@ -210,9 +235,6 @@ interface MenuLevel {
|
||||
const MenuLevelContext = createContext<MenuLevel | null>(null);
|
||||
const neverTouched = () => false;
|
||||
|
||||
/** The desktop app shows this menu natively: it renders hidden, submenus and all, to be read. */
|
||||
const NativeMenuContext = createContext(false);
|
||||
|
||||
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -259,58 +281,16 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
|
||||
const level = useMenuLevel(recentTouch);
|
||||
const { request: requestLevel } = level;
|
||||
const [nativeRefused, setNativeRefused] = useState(false);
|
||||
const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable();
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
const tDesktop = useTranslations("desktopUi.menu");
|
||||
const tDesktopRef = useRef(tDesktop);
|
||||
tDesktopRef.current = tDesktop;
|
||||
// Type-ahead: what was typed, and when.
|
||||
const typed = useRef("");
|
||||
const typedAt = useRef(0);
|
||||
|
||||
// The desktop app shows this same menu, not a native copy: a native menu
|
||||
// has no icons, and writes keys with Chromium's names rather than Windows'.
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setNativeRefused(false);
|
||||
}, [isOpen]);
|
||||
|
||||
// In the desktop app: read what rendered and show that natively. See lib/desktop/native-menu.ts.
|
||||
useEffect(() => {
|
||||
if (!nativeMode) return;
|
||||
const root = localRef.current;
|
||||
if (!root) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const read = await readSettledMenu(root, () => cancelled);
|
||||
if (cancelled) return;
|
||||
if (read.unsupported) {
|
||||
setNativeRefused(true);
|
||||
return;
|
||||
}
|
||||
const { anchor: at } = positionRef.current;
|
||||
// From a right-click it opens at the pointer, which the OS knows best.
|
||||
// From a button: under it, lined up with the edge the HTML menu would
|
||||
// use. The hidden copy is as wide as its widest submenu row, not as the
|
||||
// native menu, so the native menu's own width is estimated.
|
||||
const place = at
|
||||
? nativeMenuPoint(at, estimateNativeMenuWidth(read.items), getComputedStyle(root).direction === "rtl", ANCHOR_GAP)
|
||||
: undefined;
|
||||
const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins"));
|
||||
if (cancelled) return;
|
||||
// "More from plugins": the menu itself, with what only it can show.
|
||||
if (outcome === "html") {
|
||||
setNativeRefused(true);
|
||||
return;
|
||||
}
|
||||
if (outcome === "gone") toast.warning(tDesktopRef.current("pick_gone"));
|
||||
onCloseRef.current();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [nativeMode]);
|
||||
|
||||
const anchor = position.anchor;
|
||||
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
|
||||
|
||||
@@ -506,8 +486,20 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
if (!text) consume(false);
|
||||
return;
|
||||
default:
|
||||
// Letters and the rest: the app's shortcuts are suspended.
|
||||
if (!text) consume(false);
|
||||
// Letters and the rest: the app's shortcuts are suspended. A letter
|
||||
// goes to the next row that starts with it (type-ahead).
|
||||
if (text) return;
|
||||
consume(false);
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && e.key.trim()) {
|
||||
const now = Date.now();
|
||||
typed.current = now - typedAt.current < TYPE_AHEAD_MS ? typed.current + e.key.toLowerCase() : e.key.toLowerCase();
|
||||
typedAt.current = now;
|
||||
const target = typeAheadTarget(menuLevelItems(current), active, typed.current);
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
focusEl(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,11 +521,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
// Once placed (it is invisible, and can't take the focus, before), the
|
||||
// first item gets the focus.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isPositioned || nativeMode) return;
|
||||
if (!isOpen || !isPositioned) return;
|
||||
const root = localRef.current;
|
||||
if (!root || root.contains(document.activeElement)) return;
|
||||
return focusFirstItem(root);
|
||||
}, [isOpen, isPositioned, nativeMode]);
|
||||
}, [isOpen, isPositioned]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
localRef.current = node;
|
||||
@@ -549,7 +541,6 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const renderPosition = adjustedPosition ?? position;
|
||||
|
||||
return createPortal(
|
||||
<NativeMenuContext.Provider value={nativeMode}>
|
||||
<MenuLevelContext.Provider value={level}>
|
||||
<div
|
||||
ref={setRefs}
|
||||
@@ -564,8 +555,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
style={{
|
||||
left: renderPosition.x,
|
||||
top: renderPosition.y,
|
||||
maxWidth: `calc(100vw - ${2 * VIEWPORT_MARGIN}px)`,
|
||||
visibility: isPositioned && !nativeMode ? "visible" : "hidden",
|
||||
maxWidth: MENU_MAX_WIDTH,
|
||||
visibility: isPositioned ? "visible" : "hidden",
|
||||
}}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
@@ -588,8 +579,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</MenuLevelContext.Provider>
|
||||
</NativeMenuContext.Provider>,
|
||||
</MenuLevelContext.Provider>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -657,7 +647,7 @@ export function ContextMenuItem({
|
||||
"focus:outline-none focus-visible:bg-muted",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!disabled && "hover:bg-muted cursor-pointer",
|
||||
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus-visible:bg-destructive/10"
|
||||
destructive && !disabled && "hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
|
||||
)}
|
||||
// Hovering another entry closes an open submenu of this level, after
|
||||
// a moment, so a diagonal move into the submenu doesn't.
|
||||
@@ -675,7 +665,7 @@ export function ContextMenuItem({
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{Icon && <Icon className={cn("w-4 h-4 flex-shrink-0", destructive && !disabled && "text-destructive")} />}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
@@ -690,22 +680,86 @@ export function ContextMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
interface ContextMenuCheckItemProps {
|
||||
/** With an icon, it takes the icon column and the tick goes to the row's end. */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onClick: () => void;
|
||||
/** "radio": one of a group (a sort order, a view); "checkbox": on or off. */
|
||||
kind?: "checkbox" | "radio";
|
||||
disabled?: boolean;
|
||||
shortcut?: string;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key that does what a menu row does, at the row's end, muted, as a keycap. The
|
||||
* desktop app's native menu shows it as the item's accelerator, apart from the label
|
||||
* (lib/desktop/native-menu.ts reads `data-menu-shortcut`). Key hints mean nothing
|
||||
* without a keyboard: hidden on touch (R8).
|
||||
* A row that is on or off, or one of a group: the tick sits in the icon column, so its
|
||||
* label lines up with the rows that have icons.
|
||||
*/
|
||||
export function MenuShortcut({ keys, className }: { keys: string; className?: string }) {
|
||||
export function ContextMenuCheckItem({ icon: Icon, label, checked, onClick, kind = "checkbox", disabled = false, shortcut, testId, title, hint }: ContextMenuCheckItemProps) {
|
||||
const level = useContext(MenuLevelContext);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role={kind === "radio" ? "menuitemradio" : "menuitemcheckbox"}
|
||||
aria-checked={checked}
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(CONTEXT_MENU_ITEM_CLASS, !disabled && "hover:bg-muted")}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpens()) return;
|
||||
if (level && level.openId !== null && !level.recentTouch()) level.request(null, HOVER_CLOSE_DELAY);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (level && level.openId !== null) level.request(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (disabled) return;
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-4 h-4 shrink-0 flex items-center justify-center" aria-hidden="true">
|
||||
{checked && <Check className="w-4 h-4" />}
|
||||
</span>
|
||||
)}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 [overflow-wrap:anywhere]" data-menu-label="">{label}</span>
|
||||
)}
|
||||
{shortcut && <MenuShortcut keys={shortcut} />}
|
||||
{Icon && <Check className={cn("w-4 h-4 shrink-0", !checked && "invisible")} aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The key that does what a menu row does, at the row's end, muted, as a keycap, in the
|
||||
* app's language as Windows writes it ("Umschalt + R", "Entf" in German). Key hints mean
|
||||
* nothing without a keyboard: hidden on touch (R8).
|
||||
*/
|
||||
export function MenuShortcut({ keys, className, ...rest }: { keys: string; className?: string } & Omit<React.HTMLAttributes<HTMLElement>, "children">) {
|
||||
return (
|
||||
<kbd
|
||||
{...rest}
|
||||
data-menu-shortcut=""
|
||||
className={cn(
|
||||
"ms-auto shrink-0 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{keys}
|
||||
{formatShortcutCombo(keys)}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
@@ -752,7 +806,6 @@ export function ContextMenuSubMenu({
|
||||
hint,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const parent = useContext(MenuLevelContext);
|
||||
const native = useContext(NativeMenuContext);
|
||||
const id = useId();
|
||||
// Outside a ContextMenu (a host's own panel) it keeps its own state.
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
@@ -842,19 +895,15 @@ export function ContextMenuSubMenu({
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{native && !disabled && (
|
||||
<div data-submenu-panel="" role="menu" aria-label={label} hidden={false}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{!native && isOpen && !disabled && (
|
||||
{isOpen && !disabled && (
|
||||
<MenuLevelContext.Provider value={ownLevel}>
|
||||
<SubMenuPanel
|
||||
ref={panelRef}
|
||||
data-submenu-panel=""
|
||||
aria-label={label}
|
||||
onPlaced={onPanelPlaced}
|
||||
className="min-w-[180px] max-w-[min(20rem,calc(100vw-20px))] bg-background rounded-md shadow-lg border border-border"
|
||||
className={cn("min-w-[180px]", CONTEXT_MENU_PANEL_CLASS)}
|
||||
style={{ maxWidth: MENU_MAX_WIDTH }}
|
||||
role="menu"
|
||||
>
|
||||
{/* As tall as the window allows (it's moved up to fit), then it scrolls. */}
|
||||
|
||||
@@ -224,5 +224,30 @@
|
||||
<decimal id="DeveloperToolsAvailability_Value" valueName="DeveloperToolsAvailability" required="true" minValue="0" maxValue="2"/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="RecentFilesInAttach" class="Both" displayName="$(string.RecentFilesInAttach)" explainText="$(string.RecentFilesInAttach_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="RecentFilesInAttach">
|
||||
<parentCategory ref="Bulwark_Desktop_privacy"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="FilesDriveEnabled" class="Both" displayName="$(string.FilesDriveEnabled)" explainText="$(string.FilesDriveEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="FilesDriveEnabled">
|
||||
<parentCategory ref="Bulwark_Desktop_system"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="FilesFolderSyncEnabled" class="Both" displayName="$(string.FilesFolderSyncEnabled)" explainText="$(string.FilesFolderSyncEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="FilesFolderSyncEnabled">
|
||||
<parentCategory ref="Bulwark_Desktop_system"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="FilesDriveLetter" class="Both" displayName="$(string.FilesDriveLetter)" explainText="$(string.FilesDriveLetter_Explain)" presentation="$(presentation.FilesDriveLetter)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_system"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<text id="FilesDriveLetter_Value" valueName="FilesDriveLetter" required="true" maxLength="8"/>
|
||||
</elements>
|
||||
</policy>
|
||||
</policies>
|
||||
</policyDefinitions>
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
<string id="OpenWithEnabled_Explain">Wenn aktiviert oder nicht konfiguriert, trägt sich die App unter Windows in die Liste „Öffnen mit“ für gängige Dokumente und Bilder und in das Menü „Senden an“ ein, damit Benutzer eine Datei aus dem Dateimanager an eine neue Nachricht anhängen können. Sie wird nie zum Standardprogramm für diese Dateien. Wenn deaktiviert, entfernt die App diese Einträge für den Benutzer. Mail-Links und .eml-, .ics- und .vcf-Dateien sind nicht betroffen.</string>
|
||||
<string id="DeveloperToolsAvailability">Entwicklertools</string>
|
||||
<string id="DeveloperToolsAvailability_Explain">Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher.</string>
|
||||
<string id="RecentFilesInAttach">Zuletzt verwendete Dateien im Menü „Anfügen“ anzeigen</string>
|
||||
<string id="RecentFilesInAttach_Explain">Wenn aktiviert oder nicht konfiguriert, listet die Schaltfläche „Anfügen“ in einer neuen Nachricht die Dateien auf, die der Benutzer zuletzt geöffnet hat, so wie das Betriebssystem sie erfasst. Ein Klick fügt eine davon an. Die Liste wird beim Öffnen des Menüs gelesen und bleibt auf dem Gerät. Wenn deaktiviert, fehlt die Liste und die App lehnt es ab, sie zu lesen. Das Anfügen über den Dateidialog ist nicht betroffen.</string>
|
||||
<string id="FilesDriveEnabled">Dateien als Laufwerk zulassen</string>
|
||||
<string id="FilesDriveEnabled_Explain">Wenn aktiviert oder nicht konfiguriert, können Benutzer ihre Dateien im Dateimanager als Laufwerk anzeigen. Dateien werden beim Öffnen heruntergeladen. Vorerst nur unter Windows. Wenn deaktiviert, wird das Laufwerk nicht angeboten, und ein vorhandenes Laufwerk wird mit den Kopien der Dateien darin entfernt. Die Dateien auf dem Server sind davon nicht betroffen.</string>
|
||||
<string id="FilesFolderSyncEnabled">Synchronisieren von Ordnern mit Dateien zulassen</string>
|
||||
<string id="FilesFolderSyncEnabled_Explain">Wenn aktiviert oder nicht konfiguriert, können Benutzer einen Ordner auf dem Computer und einen Ordner in Dateien in beide Richtungen gleich halten. Wenn deaktiviert, wird dies nicht angeboten, und Ordner, die gerade synchronisiert werden, hören damit auf. Die Dateien in diesen Ordnern bleiben, wo sie sind.</string>
|
||||
<string id="FilesDriveLetter">Laufwerksbuchstabe für Dateien</string>
|
||||
<string id="FilesDriveLetter_Explain">Nur unter Windows. Ein Buchstabe von D bis Z gibt dem Laufwerk Dateien für jeden Benutzer diesen Buchstaben, und Benutzer können ihn nicht ändern. Geben Sie none ein, damit es nie einen Buchstaben bekommt. Wenn nicht konfiguriert, wählen Benutzer in den Einstellungen, und bis dahin gibt es keinen Buchstaben. Ein Wert, den die App nicht lesen kann, gilt als none.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
|
||||
@@ -86,6 +94,7 @@
|
||||
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Adressen</listBox></presentation>
|
||||
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Löschcode</label></textBox></presentation>
|
||||
<presentation id="DeveloperToolsAvailability"><decimalTextBox refId="DeveloperToolsAvailability_Value" defaultValue="2">Wert (0, 1 oder 2)</decimalTextBox></presentation>
|
||||
<presentation id="FilesDriveLetter"><textBox refId="FilesDriveLetter_Value"><label>Laufwerksbuchstabe</label></textBox></presentation>
|
||||
</presentationTable>
|
||||
</resources>
|
||||
</policyDefinitionResources>
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
<string id="OpenWithEnabled_Explain">When enabled or not configured, the app adds itself to the "Open with" list for common documents and pictures and to the "Send to" menu on Windows, so people can attach a file to a new message from the file manager. It never becomes the default program for those files. When disabled, the app removes these entries for the user. Mail links and .eml, .ics and .vcf files are not affected.</string>
|
||||
<string id="DeveloperToolsAvailability">Developer tools</string>
|
||||
<string id="DeveloperToolsAvailability_Explain">The same values as Chrome's policy of this name. 2: developer tools are off and the setting is locked. 0 or 1, or not configured: people can turn them on under Settings, after a warning. Developer tools let anyone at the keyboard read everything the app holds, mail and passwords in memory included.</string>
|
||||
<string id="RecentFilesInAttach">Show recent files in the Attach menu</string>
|
||||
<string id="RecentFilesInAttach_Explain">When enabled or not configured, the Attach button in a new message lists the files the user opened recently, as the operating system records them, so one click attaches one. The list is read when the menu opens and stays on the device. When disabled, the list is gone and the app refuses to read it. Attaching through the file dialog is not affected.</string>
|
||||
<string id="FilesDriveEnabled">Allow Files as a drive</string>
|
||||
<string id="FilesDriveEnabled_Explain">When enabled or not configured, people can show their Files in the file manager as a drive, with files downloaded when opened. Windows only for now. When disabled, the drive is not offered, and a drive that is already there is removed with the copies of files in it. Files on the server are not affected.</string>
|
||||
<string id="FilesFolderSyncEnabled">Allow syncing folders with Files</string>
|
||||
<string id="FilesFolderSyncEnabled_Explain">When enabled or not configured, people can keep a folder on the computer and a folder in Files the same, in both directions. When disabled, this is not offered and folders that sync now stop. The files in those folders stay where they are.</string>
|
||||
<string id="FilesDriveLetter">Drive letter for Files</string>
|
||||
<string id="FilesDriveLetter_Explain">Windows only. A letter from D to Z gives the Files drive that letter for every user, and they can't change it. Enter none to never give it a letter. When not configured, people choose under Settings, and there is no letter until they do. A value the app can't read counts as none.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Address of the Bulwark server</label></textBox></presentation>
|
||||
@@ -86,6 +94,7 @@
|
||||
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Addresses</listBox></presentation>
|
||||
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Wipe code</label></textBox></presentation>
|
||||
<presentation id="DeveloperToolsAvailability"><decimalTextBox refId="DeveloperToolsAvailability_Value" defaultValue="2">Value (0, 1 or 2)</decimalTextBox></presentation>
|
||||
<presentation id="FilesDriveLetter"><textBox refId="FilesDriveLetter_Value"><label>Drive letter</label></textBox></presentation>
|
||||
</presentationTable>
|
||||
</resources>
|
||||
</policyDefinitionResources>
|
||||
|
||||
@@ -69,6 +69,14 @@
|
||||
<true/>
|
||||
<key>DeveloperToolsAvailability</key>
|
||||
<integer>2</integer>
|
||||
<key>RecentFilesInAttach</key>
|
||||
<true/>
|
||||
<key>FilesDriveEnabled</key>
|
||||
<true/>
|
||||
<key>FilesFolderSyncEnabled</key>
|
||||
<true/>
|
||||
<key>FilesDriveLetter</key>
|
||||
<string>none</string>
|
||||
<key>Recommended</key>
|
||||
<dict>
|
||||
<key>UpdateChannel</key>
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
"BrowserSignIn": true,
|
||||
"OpenWithEnabled": true,
|
||||
"DeveloperToolsAvailability": 2,
|
||||
"RecentFilesInAttach": true,
|
||||
"FilesDriveEnabled": true,
|
||||
"FilesFolderSyncEnabled": true,
|
||||
"FilesDriveLetter": "none",
|
||||
"Recommended": {
|
||||
"UpdateChannel": "stable"
|
||||
}
|
||||
|
||||
@@ -226,6 +226,19 @@ describe('sending', () => {
|
||||
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('asks in the app\'s own window where the shell has them; Escape is going back', async () => {
|
||||
const show = vi.fn(async (_request: unknown) => null as unknown);
|
||||
const desktop = (window as unknown as { bulwarkDesktop: { dialogs: Record<string, unknown> } }).bulwarkDesktop;
|
||||
desktop.dialogs = { confirm, show, cancel: vi.fn() };
|
||||
useEditSessions.setState({ sessions: [session({ changed: true })] });
|
||||
const { result } = setup({ start: [chip] });
|
||||
await expect(result.current.edits.confirmSend()).resolves.toBe(false);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(show.mock.calls[0][0]).toMatchObject({ message: [{ text: 'send_stale_detail' }], buttons: [{ label: 'send_stale_back', variant: 'secondary' }, { label: 'send_stale_send' }], defaultIndex: 0 });
|
||||
show.mockResolvedValueOnce({ button: 1 });
|
||||
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('sees a save from a moment ago, which the list has not heard of', async () => {
|
||||
useEditSessions.setState({ sessions: [session()] });
|
||||
const { result } = setup({ start: [chip] });
|
||||
|
||||
@@ -82,6 +82,8 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
|
||||
act(() => result.current.handleGridPointerDown(pointer(160), "2026-09-03", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(400)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 780 });
|
||||
act(() => result.current.handleGridPointerMove(pointer(2000)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 1200 });
|
||||
act(() => result.current.handleGridPointerUp(pointer(2000)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
@@ -89,6 +91,61 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
|
||||
expect(end).toEqual(new Date(2026, 8, 3, 20, 0, 0, 0));
|
||||
});
|
||||
|
||||
// The selection said 11:15 – 16:15 and the event came out shorter: the
|
||||
// range drawn, its label and the event created must be the same.
|
||||
describe("drag-to-create creates what the selection shows", () => {
|
||||
const day = new Date(2026, 10, 2);
|
||||
let top = 0;
|
||||
const column = {
|
||||
getBoundingClientRect: () => ({ top }),
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
};
|
||||
const pointer = (clientY: number) => ({
|
||||
pointerType: "mouse", button: 0, pointerId: 1, clientY,
|
||||
target: { closest: () => null }, currentTarget: column,
|
||||
}) as unknown as React.PointerEvent<HTMLDivElement>;
|
||||
beforeEach(() => { top = 0; });
|
||||
|
||||
it("commits the range last drawn, not one measured again on release", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 975 });
|
||||
// The release reports a slightly different spot (a trackpad lifting).
|
||||
act(() => result.current.handleGridPointerUp(pointer(948)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
expect(start).toEqual(new Date(2026, 10, 2, 11, 15));
|
||||
expect(end).toEqual(new Date(2026, 10, 2, 16, 15));
|
||||
});
|
||||
|
||||
it("follows the grid when it scrolls during the drag, and creates what it then shows", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
// The wheel scrolls the grid up by an hour under a still pointer.
|
||||
top = -60;
|
||||
act(() => { window.dispatchEvent(new Event("scroll")); });
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 1035 });
|
||||
act(() => result.current.handleGridPointerUp(pointer(975)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
expect(start).toEqual(new Date(2026, 10, 2, 11, 15));
|
||||
expect(end).toEqual(new Date(2026, 10, 2, 17, 15));
|
||||
});
|
||||
|
||||
it("creates nothing when the browser takes the pointer over", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
act(() => result.current.handleGridPointerCancel());
|
||||
expect(result.current.dragCreate).toBeNull();
|
||||
act(() => result.current.handleGridPointerUp(pointer(975)));
|
||||
expect(onCreateRange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("a 23:00 click ends at midnight of the next day", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
const day = new Date(2026, 8, 3);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } fr
|
||||
import { useTranslations } from "next-intl";
|
||||
import { editAllowed, editFileInfo, followEditSessions, getEditBridge, parseEditSource, readEditedFile, takeFilesForReply, useEditSessions, type EditSource } from "@/lib/desktop/edit-attachments";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { askInWindow } from "@/lib/desktop/dialog-window";
|
||||
import { useEditAttachment } from "@/hooks/use-edit-attachment";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
@@ -203,8 +204,18 @@ export function useComposerEdits<A extends EditableAttachment>({ attachments, se
|
||||
const session = att.editSessionId ? all.find((s) => s.id === att.editSessionId) : undefined;
|
||||
return session?.changed && editFileInfo(att.file)?.hash !== session.hash ? [session] : [];
|
||||
});
|
||||
if (stale.length === 0) return true;
|
||||
// A window of the app's own; an older shell asks with the OS's message box.
|
||||
const asked = askInWindow({
|
||||
title: t("send_stale_title", { name: stale[0].name }),
|
||||
message: t("send_stale_detail"),
|
||||
buttons: [{ label: t("send_stale_back"), variant: "secondary" }, { label: t("send_stale_send") }],
|
||||
defaultIndex: 0,
|
||||
});
|
||||
const answer = asked ? await asked.answer : "failed";
|
||||
if (answer !== "failed") return Boolean(answer && "button" in answer && answer.button === 1);
|
||||
const dialogs = getDesktopBridge()?.dialogs;
|
||||
if (stale.length === 0 || !dialogs) return true;
|
||||
if (!dialogs) return true;
|
||||
const choice = await dialogs.confirm({
|
||||
type: "warning",
|
||||
message: t("send_stale_title", { name: stale[0].name }),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { ContextMenuPosition } from "@/components/ui/context-menu";
|
||||
import { nativeMenuIsOpen } from "@/lib/desktop/native-menu";
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
@@ -115,14 +114,12 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
@@ -133,8 +130,6 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
// A focused control in the menu may have opened a system dialog that
|
||||
// took the focus from the window, such as a color input's (#1366).
|
||||
if (menuRef.current?.contains(document.activeElement)) return;
|
||||
// In the desktop app the menu is native: it closes itself, and says what was picked.
|
||||
if (nativeMenuIsOpen()) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
|
||||
import { isSameDay } from "date-fns";
|
||||
import { parseDayKey } from "@/lib/calendar-scroll-window";
|
||||
|
||||
/** Days picked in the month grid, first and last in date order. */
|
||||
export interface MonthRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/** Days being picked; `keyboard` when Shift+arrows picked them (Enter creates). */
|
||||
export interface MonthSelection extends MonthRange {
|
||||
keyboard?: boolean;
|
||||
}
|
||||
|
||||
/** How long a finger rests on a day before a drag picks days instead of scrolling. */
|
||||
export const LONG_PRESS_MS = 450;
|
||||
/** A finger that moves this far before the long press is scrolling. */
|
||||
const TOUCH_SLOP_PX = 10;
|
||||
|
||||
/** The days from `a` to `b` in reading order, whichever way the drag went. */
|
||||
export function monthRange(a: Date, b: Date): MonthRange {
|
||||
return a <= b ? { start: a, end: b } : { start: b, end: a };
|
||||
}
|
||||
|
||||
export function isInMonthRange(day: Date, range: MonthRange | null | undefined): boolean {
|
||||
if (!range) return false;
|
||||
return (day >= range.start || isSameDay(day, range.start)) && (day <= range.end || isSameDay(day, range.end));
|
||||
}
|
||||
|
||||
/**
|
||||
* The day cell under a point of the month grid: the week row at that
|
||||
* height, then the cell at that position in it. Measured from the cells
|
||||
* themselves, so a right-to-left grid needs nothing extra; a point past
|
||||
* the grid takes the nearest row and cell. Not elementFromPoint: an event
|
||||
* bar over several days belongs to its first day's cell.
|
||||
*/
|
||||
export function monthDayAtPoint(root: HTMLElement, x: number, y: number): Date | null {
|
||||
const rows = Array.from(root.querySelectorAll<HTMLElement>("[data-week]"));
|
||||
if (rows.length === 0) return null;
|
||||
const nearest = <T extends HTMLElement>(items: T[], from: (rect: DOMRect) => [number, number], at: number): T => {
|
||||
let best = items[0];
|
||||
let bestDistance = Infinity;
|
||||
for (const item of items) {
|
||||
const [low, high] = from(item.getBoundingClientRect());
|
||||
const distance = at < low ? low - at : at >= high ? at - high + 1 : 0;
|
||||
if (distance < bestDistance) {
|
||||
best = item;
|
||||
bestDistance = distance;
|
||||
if (distance === 0) break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const row = nearest(rows, (rect) => [rect.top, rect.bottom], y);
|
||||
const cells = Array.from(row.querySelectorAll<HTMLElement>("[data-month-day]"));
|
||||
if (cells.length === 0) return null;
|
||||
const cell = nearest(cells, (rect) => [rect.left, rect.right], x);
|
||||
return parseDayKey(cell.dataset.monthDay ?? "");
|
||||
}
|
||||
|
||||
/** What a press on the grid hits that isn't empty day space. */
|
||||
const NOT_EMPTY_SPACE = "[data-month-entry], button, a, input, select, textarea, [role='button'], [data-calendar-event]";
|
||||
|
||||
interface PressState {
|
||||
pointerId: number;
|
||||
touch: boolean;
|
||||
anchor: Date;
|
||||
current: Date;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Days are being picked (mouse: the pointer left the first day; touch: the long press). */
|
||||
active: boolean;
|
||||
/** The touch's own context menu, held back until it is clear whether days were picked. */
|
||||
heldMenu: { event: MouseEvent; day: Date } | null;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface UseMonthRangeSelectOptions {
|
||||
/** The month grid's scroll container: its rows carry data-week, its cells data-month-day. */
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
/** Off when no calendar can take a new event. */
|
||||
enabled: boolean;
|
||||
/** Days were picked: open the quick editor for them. */
|
||||
onCreateRange: (start: Date, end: Date) => void;
|
||||
/** A long press that picked no other day keeps opening the day's menu. */
|
||||
onContextMenuDay?: (event: MouseEvent, day: Date) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking days in the month view to create an event over them, as Google
|
||||
* Calendar and Outlook do: press on a day's empty space and drag to another
|
||||
* day (across week rows in reading order, either way), or Shift+arrows from
|
||||
* the focused day and Enter. On touch a long press starts it, so a swipe
|
||||
* still scrolls. The days go to the same quick editor as a drag in the week
|
||||
* and day views.
|
||||
*/
|
||||
export function useMonthRangeSelect({ scrollRef, enabled, onCreateRange, onContextMenuDay }: UseMonthRangeSelectOptions) {
|
||||
const [selection, setSelection] = useState<MonthSelection | null>(null);
|
||||
const pressRef = useRef<PressState | null>(null);
|
||||
// The click that ends a drag is not a click on the day it ended on.
|
||||
const wasDragging = useRef(false);
|
||||
// A touch drag's own context menu (Windows sends it after the finger lifts).
|
||||
const muteMenuUntil = useRef(0);
|
||||
// Shift+arrows: the day the keyboard selection started on.
|
||||
const keyAnchor = useRef<Date | null>(null);
|
||||
|
||||
const show = useCallback((press: PressState) => {
|
||||
const range = monthRange(press.anchor, press.current);
|
||||
setSelection((prev) => (prev && isSameDay(prev.start, range.start) && isSameDay(prev.end, range.end) ? prev : range));
|
||||
}, []);
|
||||
|
||||
const end = useCallback(() => {
|
||||
const press = pressRef.current;
|
||||
pressRef.current = null;
|
||||
if (press) {
|
||||
if (press.timer) clearTimeout(press.timer);
|
||||
press.stop();
|
||||
}
|
||||
setSelection(null);
|
||||
return press;
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
const press = pressRef.current;
|
||||
if (press?.timer) clearTimeout(press.timer);
|
||||
press?.stop();
|
||||
}, []);
|
||||
|
||||
const follow = useCallback((press: PressState) => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const day = monthDayAtPoint(root, press.x, press.y);
|
||||
if (!day) return;
|
||||
if (!press.active) {
|
||||
if (isSameDay(day, press.anchor)) return;
|
||||
press.active = true;
|
||||
}
|
||||
press.current = day;
|
||||
show(press);
|
||||
}, [scrollRef, show]);
|
||||
|
||||
const activate = useCallback((press: PressState, root: HTMLElement) => {
|
||||
press.active = true;
|
||||
show(press);
|
||||
// From here a moving finger picks days: the grid must not scroll.
|
||||
const holdStill = (e: TouchEvent) => { if (e.cancelable) e.preventDefault(); };
|
||||
root.addEventListener("touchmove", holdStill, { passive: false });
|
||||
const previous = press.stop;
|
||||
press.stop = () => {
|
||||
previous();
|
||||
root.removeEventListener("touchmove", holdStill);
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {
|
||||
if (!enabled || !e.isPrimary) return;
|
||||
const touch = e.pointerType === "touch";
|
||||
if (!touch && e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(NOT_EMPTY_SPACE)) return;
|
||||
const cell = target.closest<HTMLElement>("[data-month-day]");
|
||||
const root = scrollRef.current;
|
||||
if (!cell || !root) return;
|
||||
end();
|
||||
keyAnchor.current = null;
|
||||
const anchor = parseDayKey(cell.dataset.monthDay ?? "");
|
||||
const press: PressState = {
|
||||
pointerId: e.pointerId, touch, anchor, current: anchor, x: e.clientX, y: e.clientY,
|
||||
active: false, heldMenu: null, timer: null, stop: () => {},
|
||||
};
|
||||
// The wheel moves the grid under a pointer that stands still: follow it.
|
||||
const onScroll = () => { if (press.active) follow(press); };
|
||||
root.addEventListener("scroll", onScroll);
|
||||
press.stop = () => root.removeEventListener("scroll", onScroll);
|
||||
if (touch) {
|
||||
press.timer = setTimeout(() => {
|
||||
press.timer = null;
|
||||
if (pressRef.current === press) activate(press, root);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
pressRef.current = press;
|
||||
}, [enabled, scrollRef, end, follow, activate]);
|
||||
|
||||
const onPointerMove = useCallback((e: PointerEvent<HTMLElement>) => {
|
||||
const press = pressRef.current;
|
||||
if (!press || e.pointerId !== press.pointerId) return;
|
||||
if (press.touch && !press.active) {
|
||||
// A swipe before the long press: the grid scrolls, nothing is picked.
|
||||
if (Math.hypot(e.clientX - press.x, e.clientY - press.y) > TOUCH_SLOP_PX) end();
|
||||
return;
|
||||
}
|
||||
press.x = e.clientX;
|
||||
press.y = e.clientY;
|
||||
const wasActive = press.active;
|
||||
follow(press);
|
||||
if (press.active && !wasActive && !press.touch) {
|
||||
try { e.currentTarget.setPointerCapture(press.pointerId); } catch { /* the pointer is gone */ }
|
||||
}
|
||||
}, [end, follow]);
|
||||
|
||||
const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {
|
||||
const press = pressRef.current;
|
||||
if (!press || e.pointerId !== press.pointerId) return;
|
||||
end();
|
||||
if (!press.active) return;
|
||||
wasDragging.current = true;
|
||||
requestAnimationFrame(() => { wasDragging.current = false; });
|
||||
try { e.currentTarget.releasePointerCapture(press.pointerId); } catch { /* not captured */ }
|
||||
const range = monthRange(press.anchor, press.current);
|
||||
if (press.touch && isSameDay(range.start, range.end)) {
|
||||
// A long press that stayed on its day: the day's menu, as before.
|
||||
if (press.heldMenu) onContextMenuDay?.(press.heldMenu.event, press.heldMenu.day);
|
||||
return;
|
||||
}
|
||||
if (press.touch) muteMenuUntil.current = Date.now() + 1000;
|
||||
onCreateRange(range.start, range.end);
|
||||
}, [end, onCreateRange, onContextMenuDay]);
|
||||
|
||||
const onPointerCancel = useCallback((e: PointerEvent<HTMLElement>) => {
|
||||
const press = pressRef.current;
|
||||
if (!press || e.pointerId !== press.pointerId) return;
|
||||
end();
|
||||
}, [end]);
|
||||
|
||||
/** Captured before the cells' own handler: a touch drag's long press is not a right-click. */
|
||||
const onContextMenuCapture = useCallback((e: MouseEvent<HTMLElement>) => {
|
||||
const press = pressRef.current;
|
||||
if (press?.touch) {
|
||||
// The browser's long press can come before ours: it starts the pick.
|
||||
if (!press.active && scrollRef.current) {
|
||||
if (press.timer) clearTimeout(press.timer);
|
||||
press.timer = null;
|
||||
activate(press, scrollRef.current);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
press.heldMenu = { event: e, day: press.anchor };
|
||||
return;
|
||||
}
|
||||
if (Date.now() < muteMenuUntil.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [scrollRef, activate]);
|
||||
|
||||
// --- Keyboard ---
|
||||
|
||||
/** Shift+arrow from `from` to `to`: the selection grows or shrinks from where it started. */
|
||||
const extendTo = useCallback((from: Date, to: Date) => {
|
||||
if (!enabled) return;
|
||||
if (!keyAnchor.current) keyAnchor.current = from;
|
||||
setSelection({ ...monthRange(keyAnchor.current, to), keyboard: true });
|
||||
}, [enabled]);
|
||||
|
||||
/** Enter: create over the keyboard selection. False when there is none. */
|
||||
const commitKeyboard = useCallback((): boolean => {
|
||||
if (!keyAnchor.current || !selection) return false;
|
||||
const range = selection;
|
||||
keyAnchor.current = null;
|
||||
setSelection(null);
|
||||
onCreateRange(range.start, range.end);
|
||||
return true;
|
||||
}, [selection, onCreateRange]);
|
||||
|
||||
/** Escape or a plain arrow: the keyboard selection goes. False when there was none. */
|
||||
const clearKeyboard = useCallback((): boolean => {
|
||||
if (!keyAnchor.current) return false;
|
||||
keyAnchor.current = null;
|
||||
setSelection(null);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selection,
|
||||
wasDragging,
|
||||
pointerHandlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onContextMenuCapture },
|
||||
extendTo,
|
||||
commitKeyboard,
|
||||
clearKeyboard,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -72,6 +72,17 @@ interface UseTimeGridInteractionsOptions {
|
||||
onMoveEvent?: MoveEventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* The range a drag from `anchorMinutes` to `currentMinutes` (both snapped)
|
||||
* selects, or null when it is shorter than a quarter of an hour. The
|
||||
* drawn selection, its label and the event it creates all come from this.
|
||||
*/
|
||||
export function dragCreateRange(anchorMinutes: number, currentMinutes: number): { startMinutes: number; endMinutes: number } | null {
|
||||
const startMinutes = Math.min(anchorMinutes, currentMinutes);
|
||||
const endMinutes = Math.max(anchorMinutes, currentMinutes);
|
||||
return endMinutes - startMinutes >= 15 ? { startMinutes, endMinutes } : null;
|
||||
}
|
||||
|
||||
/** `date` moved by `minutes` on the local clock, not by elapsed time. */
|
||||
function addWallClockMinutes(date: Date, minutes: number): Date {
|
||||
const shifted = new Date(date);
|
||||
@@ -101,6 +112,9 @@ export function useTimeGridInteractions({
|
||||
const wasDragging = useRef(false);
|
||||
|
||||
// --- Drag-to-create ---
|
||||
// The range on screen is the range created: the release commits the
|
||||
// selection last drawn rather than measuring the pointer again, and a
|
||||
// scroll during the drag redraws it under the pointer.
|
||||
const dragRef = useRef<{
|
||||
dayKey: string;
|
||||
dayDate: Date;
|
||||
@@ -108,10 +122,31 @@ export function useTimeGridInteractions({
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
captured: boolean;
|
||||
column: HTMLElement;
|
||||
lastY: number;
|
||||
range: { startMinutes: number; endMinutes: number } | null;
|
||||
stopScrollWatch?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [dragCreate, setDragCreate] = useState<DragCreateState | null>(null);
|
||||
|
||||
const updateDragRange = useCallback((clientY: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
drag.lastY = clientY;
|
||||
const range = dragCreateRange(drag.startMinutes, snapToMinutes(clientY, drag.column.getBoundingClientRect().top));
|
||||
drag.range = range;
|
||||
setDragCreate(range ? { dayKey: drag.dayKey, ...range } : null);
|
||||
}, [snapToMinutes]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
drag?.stopScrollWatch?.();
|
||||
setDragCreate(null);
|
||||
return drag;
|
||||
}, []);
|
||||
|
||||
const handleGridPointerDown = useCallback((
|
||||
e: PointerEvent<HTMLDivElement>,
|
||||
dayKey: string,
|
||||
@@ -125,34 +160,34 @@ export function useTimeGridInteractions({
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const minutes = snapToMinutes(e.clientY, rect.top);
|
||||
|
||||
dragRef.current?.stopScrollWatch?.();
|
||||
dragRef.current = {
|
||||
dayKey, dayDate, startMinutes: minutes,
|
||||
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
||||
column: e.currentTarget, lastY: e.clientY, range: null,
|
||||
};
|
||||
}, [snapToMinutes, isMobile]);
|
||||
|
||||
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
if (!dragRef.current.captured) {
|
||||
if (Math.abs(e.clientY - dragRef.current.startY) < 5) return;
|
||||
dragRef.current.captured = true;
|
||||
e.currentTarget.setPointerCapture(dragRef.current.pointerId);
|
||||
if (!drag.captured) {
|
||||
if (Math.abs(e.clientY - drag.startY) < 5) return;
|
||||
drag.captured = true;
|
||||
e.currentTarget.setPointerCapture(drag.pointerId);
|
||||
// The wheel or the browser's autoscroll moves the grid under a
|
||||
// pointer that stands still: follow it.
|
||||
const onScroll = () => updateDragRange(drag.lastY);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
drag.stopScrollWatch = () => window.removeEventListener("scroll", onScroll, true);
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const currentMinutes = snapToMinutes(e.clientY, rect.top);
|
||||
const start = Math.min(dragRef.current.startMinutes, currentMinutes);
|
||||
const end = Math.max(dragRef.current.startMinutes, currentMinutes);
|
||||
|
||||
setDragCreate(end > start ? { dayKey: dragRef.current.dayKey, startMinutes: start, endMinutes: end } : null);
|
||||
}, [snapToMinutes]);
|
||||
updateDragRange(e.clientY);
|
||||
}, [updateDragRange]);
|
||||
|
||||
const handleGridPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
setDragCreate(null);
|
||||
|
||||
const drag = endDrag();
|
||||
if (!drag || !drag.captured) return;
|
||||
|
||||
wasDragging.current = true;
|
||||
@@ -160,19 +195,22 @@ export function useTimeGridInteractions({
|
||||
|
||||
try { e.currentTarget.releasePointerCapture(drag.pointerId); } catch { /* may already be released */ }
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const endMinutes = snapToMinutes(e.clientY, rect.top);
|
||||
const start = Math.min(drag.startMinutes, endMinutes);
|
||||
const end = Math.max(drag.startMinutes, endMinutes);
|
||||
|
||||
if (end - start < 15) return;
|
||||
const range = drag.range;
|
||||
if (!range) return;
|
||||
|
||||
const startDate = new Date(drag.dayDate);
|
||||
startDate.setHours(Math.floor(start / 60), start % 60, 0, 0);
|
||||
startDate.setHours(Math.floor(range.startMinutes / 60), range.startMinutes % 60, 0, 0);
|
||||
const endDate = new Date(drag.dayDate);
|
||||
endDate.setHours(Math.floor(end / 60), end % 60, 0, 0);
|
||||
endDate.setHours(Math.floor(range.endMinutes / 60), range.endMinutes % 60, 0, 0);
|
||||
onCreateRange(startDate, endDate);
|
||||
}, [snapToMinutes, onCreateRange]);
|
||||
}, [endDrag, onCreateRange]);
|
||||
|
||||
useEffect(() => () => dragRef.current?.stopScrollWatch?.(), []);
|
||||
|
||||
/** The browser took the pointer over (a touch scroll, a system gesture): nothing is created. */
|
||||
const handleGridPointerCancel = useCallback(() => {
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
@@ -438,6 +476,7 @@ export function useTimeGridInteractions({
|
||||
handleGridPointerDown,
|
||||
handleGridPointerMove,
|
||||
handleGridPointerUp,
|
||||
handleGridPointerCancel,
|
||||
resizeVisual,
|
||||
handleResizePointerDown,
|
||||
handleResizePointerMove,
|
||||
|
||||
@@ -392,6 +392,8 @@ test('item 2: the ARF format arrives as a feedback report', async ({ browser })
|
||||
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Click here' });
|
||||
const alice = await userPage(browser, ENT.alice);
|
||||
await alice.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`).first().click({ button: 'right', timeout: 60_000 });
|
||||
// A row's menu has it under More actions.
|
||||
await alice.getByTestId('ctx-more').click();
|
||||
await alice.getByTestId('ctx-report-phishing').click();
|
||||
await alice.getByTestId('phishing-report-confirm').click();
|
||||
await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
import { addDays, format, startOfWeek } from 'date-fns';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability report 2026-11-04: creating by dragging over the calendar.
|
||||
* - Month view: a drag over days (across week rows, either way), Shift+
|
||||
* arrows and Enter, a long press and drag on a phone, all open the quick
|
||||
* editor (the full one on a phone) for an all-day event over the days.
|
||||
* - Week view: the event created is the range the selection showed, the
|
||||
* selection is drawn over the events it covers, the new event takes a
|
||||
* column beside an overlapping one, and Save works without a title.
|
||||
* Against a VM 106 Stalwart (1.0 and 0.16); every save is checked on the
|
||||
* server and removed again.
|
||||
*
|
||||
* Account: dso-ux-pim, password IT_DSO_PASSWORD. Items are titled
|
||||
* "MDRAG <run> …" (the untitled one is found by its time) and deleted.
|
||||
* Screenshots go to IT_SHOTS_DIR, named month-drag-<server>-….
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CAL = 'urn:ietf:params:jmap:calendars';
|
||||
const RUN = Date.now().toString(36);
|
||||
const TZ = 'Europe/Berlin';
|
||||
const prefix = `MDRAG ${RUN}`;
|
||||
const SERVER = process.env.IT_ENT_SERVER ?? 'unknown';
|
||||
const SHOTS = process.env.IT_SHOTS_DIR ?? path.join(tmpdir(), 'bulwark-month-drag');
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Obj = { id: string; title?: string; start?: string; duration?: string; showWithoutTime?: boolean; calendarIds?: Record<string, boolean> };
|
||||
|
||||
async function events(c: JmapClient): Promise<Obj[]> {
|
||||
const r = await c.request([
|
||||
['CalendarEvent/query', { accountId: c.accountId, limit: 2000 }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: c.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: ['id', 'title', 'start', 'duration', 'showWithoutTime', 'calendarIds'],
|
||||
}, 'g'],
|
||||
], [CORE, CAL]);
|
||||
return r.methodResponses[1][1].list as Obj[];
|
||||
}
|
||||
|
||||
async function destroy(c: JmapClient, ids: string[]): Promise<void> {
|
||||
if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]);
|
||||
}
|
||||
|
||||
async function defaultCalendar(c: JmapClient): Promise<string> {
|
||||
const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'isDefault'] }, '0']], [CORE, CAL]);
|
||||
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
|
||||
return (list.find((cal) => cal.isDefault) ?? list[0]).id;
|
||||
}
|
||||
|
||||
async function create(c: JmapClient, title: string, start: string, duration: string, allDay = false): Promise<void> {
|
||||
await c.request([['CalendarEvent/set', {
|
||||
accountId: c.accountId,
|
||||
create: { n: { '@type': 'Event', title, start, duration, timeZone: allDay ? null : TZ, showWithoutTime: allDay, calendarIds: { [await defaultCalendar(c)]: true } } },
|
||||
}, '0']], [CORE, CAL]);
|
||||
}
|
||||
|
||||
/** Waits for the server to hold an event that `match` accepts. */
|
||||
async function serverEvent(c: JmapClient, match: (o: Obj) => boolean): Promise<Obj> {
|
||||
let found: Obj | undefined;
|
||||
await expect.poll(async () => {
|
||||
found = (await events(c)).find(match);
|
||||
return !!found;
|
||||
}, { timeout: 20000 }).toBe(true);
|
||||
return found!;
|
||||
}
|
||||
|
||||
async function signIn(browser: Browser, opts: { dark?: boolean; width?: number; phone?: boolean } = {}): Promise<Page> {
|
||||
const base = contextOptions(opts.phone);
|
||||
const context = await browser.newContext({
|
||||
...base,
|
||||
timezoneId: TZ,
|
||||
colorScheme: opts.dark ? 'dark' : 'light',
|
||||
viewport: opts.phone ? base.viewport : { width: opts.width ?? 1366, height: 860 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', A);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.locator('a[href$="/calendar"]').first().waitFor({ timeout: 60000 });
|
||||
await page.locator('a[href$="/calendar"]').first().click();
|
||||
await page.locator('[data-testid="calendar-item"]').first().waitFor({ timeout: 60000 }).catch(() => {});
|
||||
return page;
|
||||
}
|
||||
|
||||
async function view(page: Page, key: 'm' | 'w'): Promise<void> {
|
||||
await page.locator('body').click({ position: { x: 1, y: 1 } }).catch(() => {});
|
||||
await page.keyboard.press(key);
|
||||
await page.keyboard.press('t');
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function shot(page: Page, name: string): Promise<void> {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await page.screenshot({ path: path.join(SHOTS, `month-drag-${SERVER}-${name}.png`) });
|
||||
}
|
||||
|
||||
const cell = (page: Page, day: Date) => page.locator(`[data-month-day="${format(day, 'yyyy-MM-dd')}"]`);
|
||||
const picked = (page: Page) => page.$$eval('[data-month-day][aria-selected="true"]', (els) => els.map((e) => e.getAttribute('data-month-day')));
|
||||
const quick = (page: Page) => page.getByRole('dialog').filter({ has: page.getByPlaceholder('Add title') });
|
||||
|
||||
/** Empty space of a day: just above the bottom edge of its cell. */
|
||||
async function emptySpot(page: Page, day: Date): Promise<{ x: number; y: number }> {
|
||||
const box = (await cell(page, day).boundingBox())!;
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height - 5 };
|
||||
}
|
||||
|
||||
async function dragDays(page: Page, from: Date, to: Date, release = true): Promise<void> {
|
||||
const a = await emptySpot(page, from);
|
||||
const b = await emptySpot(page, to);
|
||||
await page.mouse.move(a.x, a.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 10; i++) await page.mouse.move(a.x + ((b.x - a.x) * i) / 10, a.y + ((b.y - a.y) * i) / 10);
|
||||
if (release) await page.mouse.up();
|
||||
}
|
||||
|
||||
const days = (from: Date, to: Date) => {
|
||||
const out: string[] = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) out.push(format(d, 'yyyy-MM-dd'));
|
||||
return out;
|
||||
};
|
||||
|
||||
// Next week's Thursday to the Tuesday after: two week rows.
|
||||
const nextMonday = addDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 7);
|
||||
const thursday = addDays(nextMonday, 3);
|
||||
const tuesday = addDays(nextMonday, 8);
|
||||
const thisThursday = addDays(nextMonday, -4);
|
||||
|
||||
test.describe('Creating by dragging over the calendar', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
let c: JmapClient;
|
||||
const untitled: string[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
c = await JmapClient.connect(A, PASSWORD);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const all = await events(c);
|
||||
await destroy(c, [...all.filter((o) => o.title?.startsWith(prefix)).map((o) => o.id), ...untitled]);
|
||||
});
|
||||
|
||||
test('month: a drag from Thursday to next Tuesday creates one all-day event over the days', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser);
|
||||
await view(page, 'm');
|
||||
await dragDays(page, thursday, tuesday, false);
|
||||
// The days in reading order, wrapping at the end of the row.
|
||||
expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday)));
|
||||
await shot(page, 'dragging-light-1366');
|
||||
await page.mouse.up();
|
||||
|
||||
const editor = quick(page);
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`);
|
||||
await expect(editor).toContainText('All day');
|
||||
// The placeholder spans the days while the editor is open.
|
||||
await expect(page.locator('[data-pending-preview]')).toHaveCount(2);
|
||||
await shot(page, 'popover-light-1366');
|
||||
await editor.getByPlaceholder('Add title').fill(`${prefix} trip`);
|
||||
await editor.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(editor).toBeHidden();
|
||||
|
||||
const saved = await serverEvent(c, (o) => o.title === `${prefix} trip`);
|
||||
expect(saved).toMatchObject({ start: `${format(thursday, 'yyyy-MM-dd')}T00:00:00`, duration: 'P6D', showWithoutTime: true });
|
||||
expect(Object.keys(saved.calendarIds ?? {})).toEqual([await defaultCalendar(c)]);
|
||||
await expect(page.locator('[data-month-day] [role="button"], [data-month-day] button').filter({ hasText: `${prefix} trip` }).first()).toBeVisible();
|
||||
await shot(page, 'saved-light-1366');
|
||||
await destroy(c, [saved.id]);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('month: backwards works, and Escape cancels and clears the mark (dark)', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser, { dark: true });
|
||||
await view(page, 'm');
|
||||
await dragDays(page, tuesday, thursday, false);
|
||||
await shot(page, 'dragging-dark-1366');
|
||||
await page.mouse.up();
|
||||
const editor = quick(page);
|
||||
await expect(editor).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`);
|
||||
await shot(page, 'popover-dark-1366');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(editor).toBeHidden();
|
||||
expect((await picked(page)).filter((d) => days(thursday, tuesday).includes(d!))).toEqual([]);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('month: "More options" opens the full editor on the same days; a click still opens one day', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser);
|
||||
await view(page, 'm');
|
||||
await dragDays(page, thursday, tuesday);
|
||||
await quick(page).getByRole('button', { name: 'More options' }).click();
|
||||
await expect(page.locator('input[type="date"]').first()).toHaveValue(format(thursday, 'yyyy-MM-dd'));
|
||||
await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd'));
|
||||
await expect(page.getByLabel('All-day event')).toBeChecked();
|
||||
await shot(page, 'more-options-light-1366');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// A single click on empty space: the quick editor for that day, all day.
|
||||
const spot = await emptySpot(page, thursday);
|
||||
await page.mouse.click(spot.x, spot.y);
|
||||
await expect(quick(page)).toContainText(format(thursday, 'EEEE, MMMM d'));
|
||||
await expect(quick(page)).not.toContainText(' – ');
|
||||
await expect(quick(page)).toContainText('All day');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('month: Shift+arrows pick days and Enter opens the quick editor for them', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser);
|
||||
await view(page, 'm');
|
||||
await page.locator(`[data-day-button="${format(thursday, 'yyyy-MM-dd')}"]`).focus();
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, addDays(thursday, 2))));
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(quick(page)).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(addDays(thursday, 2), 'EEEE, MMMM d')}`);
|
||||
await shot(page, 'keyboard-light-1366');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('month: moving an event by drag and opening it still work', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
// Next week's Sunday, a quiet day, to its Saturday.
|
||||
const sunday = addDays(nextMonday, 6);
|
||||
await create(c, `${prefix} move`, `${format(sunday, 'yyyy-MM-dd')}T00:00:00`, 'P1D', true);
|
||||
const page = await signIn(browser);
|
||||
await view(page, 'm');
|
||||
const bar = page.getByRole('button', { name: new RegExp(`${prefix} move`) }).first();
|
||||
await bar.dragTo(cell(page, addDays(sunday, -1)), { targetPosition: { x: 40, y: 90 } });
|
||||
const moved = await serverEvent(c, (o) => o.title === `${prefix} move` && o.start === `${format(addDays(sunday, -1), 'yyyy-MM-dd')}T00:00:00`);
|
||||
expect(moved.duration).toBe('P1D');
|
||||
await page.getByRole('button', { name: new RegExp(`${prefix} move`) }).first().click();
|
||||
await expect(page.getByRole('dialog').filter({ hasText: `${prefix} move` }).first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`month at 800 px (${dark ? 'dark' : 'light'}): a drag over days opens an editor on them`, async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser, { width: 800, dark });
|
||||
await view(page, 'm');
|
||||
await dragDays(page, thursday, tuesday, false);
|
||||
expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday)));
|
||||
await shot(page, `dragging-${dark ? 'dark' : 'light'}-800`);
|
||||
await page.mouse.up();
|
||||
// A narrow pane is the phone layout: the full editor; else the quick one.
|
||||
const full = page.locator('input[type="date"]').first();
|
||||
await expect(quick(page).or(full)).toBeVisible();
|
||||
if (await full.isVisible()) {
|
||||
await expect(full).toHaveValue(format(thursday, 'yyyy-MM-dd'));
|
||||
await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd'));
|
||||
} else {
|
||||
await expect(quick(page)).toContainText(`${format(thursday, 'EEEE, MMMM d')} – ${format(tuesday, 'EEEE, MMMM d')}`);
|
||||
}
|
||||
await shot(page, `editor-${dark ? 'dark' : 'light'}-800`);
|
||||
await page.context().close();
|
||||
});
|
||||
}
|
||||
|
||||
test('phone: a long press and drag picks days; a swipe still scrolls', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await signIn(browser, { phone: true });
|
||||
await view(page, 'm');
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const touch = (type: string, x?: number, y?: number) => cdp.send('Input.dispatchTouchEvent', {
|
||||
type, touchPoints: x === undefined ? [] : [{ x, y }],
|
||||
} as never);
|
||||
await cell(page, thursday).scrollIntoViewIfNeeded();
|
||||
const a = await emptySpot(page, thursday);
|
||||
const b = await emptySpot(page, tuesday);
|
||||
await touch('touchStart', a.x, a.y);
|
||||
await page.waitForTimeout(700);
|
||||
for (let i = 1; i <= 8; i++) await touch('touchMove', a.x + ((b.x - a.x) * i) / 8, a.y + ((b.y - a.y) * i) / 8);
|
||||
expect(await picked(page)).toEqual(expect.arrayContaining(days(thursday, tuesday)));
|
||||
await shot(page, 'phone-long-press');
|
||||
await touch('touchEnd');
|
||||
// Phones get the full editor, on the same days.
|
||||
await expect(page.locator('input[type="date"]').first()).toHaveValue(format(thursday, 'yyyy-MM-dd'));
|
||||
await expect(page.locator('input[type="date"]').nth(1)).toHaveValue(format(tuesday, 'yyyy-MM-dd'));
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
const scroller = page.locator('[role="grid"] > div').first();
|
||||
const before = await scroller.evaluate((el) => el.scrollTop);
|
||||
const s = await emptySpot(page, thursday);
|
||||
await touch('touchStart', s.x, s.y);
|
||||
for (let i = 1; i <= 6; i++) await touch('touchMove', s.x, s.y - i * 40);
|
||||
await touch('touchEnd');
|
||||
await page.waitForTimeout(600);
|
||||
expect(await scroller.evaluate((el) => el.scrollTop)).not.toBe(before);
|
||||
expect((await picked(page)).length).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator('input[type="date"]')).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('week: the event created is the range the selection showed, beside the event it overlaps', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const day = format(thisThursday, 'yyyy-MM-dd');
|
||||
await create(c, `${prefix} Lernblock`, `${day}T12:30:00`, 'PT1H');
|
||||
for (const dark of [false, true]) {
|
||||
const page = await signIn(browser, { dark });
|
||||
await view(page, 'w');
|
||||
const slot = page.locator(`[data-slot="${day}T11"]`);
|
||||
await slot.scrollIntoViewIfNeeded();
|
||||
const box = (await slot.boundingBox())!;
|
||||
const y = (minutes: number) => box.y + ((minutes - 660) / 60) * box.height;
|
||||
const x = box.x + box.width / 2;
|
||||
await page.mouse.move(x, y(675));
|
||||
await page.mouse.down();
|
||||
for (let m = 690; m <= 975; m += 15) await page.mouse.move(x, y(m));
|
||||
const selection = page.locator('[data-drag-selection]');
|
||||
await expect(selection).toContainText('11:15 – 16:15');
|
||||
// Drawn over the event it covers: the event doesn't show through.
|
||||
// (Both sit in the day's column: the higher z-index is on top, and the fill has no transparency.)
|
||||
const looks = await page.evaluate((title) => {
|
||||
const sel = document.querySelector('[data-drag-selection]')!;
|
||||
const ev = Array.from(document.querySelectorAll('[data-calendar-event]')).find((el) => el.textContent?.includes(title))!;
|
||||
return { selZ: Number(getComputedStyle(sel).zIndex), evZ: Number(getComputedStyle(ev).zIndex), fill: getComputedStyle(sel).backgroundColor, sameColumn: sel.parentElement === ev.parentElement };
|
||||
}, `${prefix} Lernblock`);
|
||||
expect(looks.sameColumn).toBe(true);
|
||||
expect(looks.selZ).toBeGreaterThan(looks.evZ);
|
||||
expect(looks.fill).not.toMatch(/rgba\(.*,\s*0(\.\d+)?\)|\/\s*0?\.\d+\)|transparent/);
|
||||
await shot(page, `week-dragging-${dark ? 'dark' : 'light'}-1366`);
|
||||
await page.mouse.up();
|
||||
const editor = quick(page);
|
||||
await expect(editor).toContainText('11:15 – 16:15');
|
||||
// The new event and the one it overlaps share the day's width.
|
||||
const placeholder = (await page.locator('[data-pending-preview]').boundingBox())!;
|
||||
const lernblock = (await page.locator('div[data-calendar-event]').filter({ hasText: `${prefix} Lernblock` }).first().boundingBox())!;
|
||||
expect(placeholder.x + placeholder.width <= lernblock.x + 1 || lernblock.x + lernblock.width <= placeholder.x + 1).toBe(true);
|
||||
await shot(page, `week-popover-${dark ? 'dark' : 'light'}-1366`);
|
||||
if (!dark) {
|
||||
// Save without a title.
|
||||
await editor.getByRole('button', { name: 'Save' }).click();
|
||||
const saved = await serverEvent(c, (o) => o.start === `${day}T11:15:00` && o.title === '(No title)');
|
||||
untitled.push(saved.id);
|
||||
expect(saved.duration).toBe('PT5H');
|
||||
await page.waitForTimeout(1000);
|
||||
await shot(page, 'week-saved-light-1366');
|
||||
// Out of the way of the next drag, which starts at the same spot.
|
||||
await destroy(c, [saved.id]);
|
||||
} else {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,12 +38,12 @@ afterEach(() => {
|
||||
|
||||
describe('the menu bar in the desktop app', () => {
|
||||
type BarEvent = { visible: boolean; open?: string; pinned?: boolean };
|
||||
type Line = { key: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] };
|
||||
type Line = { key: string; id?: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] };
|
||||
|
||||
const line = (key: string, label: string, extra: Partial<Line> = {}): Line => ({ key, label, type: 'normal', enabled: true, mnemonic: 0, ...extra });
|
||||
const MENUS = [
|
||||
{ id: 'file', label: 'File', mnemonic: 'f', items: [
|
||||
line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N' }),
|
||||
line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N', id: 'file.newMessage' }),
|
||||
line('0/1', 'Print…', { enabled: false, mnemonic: 0 }),
|
||||
{ key: '0/2', label: '', type: 'separator', enabled: false },
|
||||
line('0/3', 'Exit', { mnemonic: 1, accelerator: 'Ctrl+Q' }),
|
||||
@@ -143,6 +143,9 @@ describe('the menu bar in the desktop app', () => {
|
||||
await waitFor(() => expect(document.activeElement).toBe(newMessage));
|
||||
expect(newMessage.querySelector('[data-menu-label]')?.textContent).toBe('New message');
|
||||
expect(newMessage.querySelector('[data-menu-keys]')?.textContent).toBe('Ctrl+N');
|
||||
// Drawn as every menu's rows: its icon (by the shell's id) and the key as a keycap.
|
||||
expect(newMessage.querySelector('svg')).not.toBeNull();
|
||||
expect(newMessage.querySelector('[data-menu-keys]')?.hasAttribute('data-menu-shortcut')).toBe(true);
|
||||
expect(within(menu).getByRole('menuitem', { name: /Print/ }).getAttribute('aria-disabled')).toBe('true');
|
||||
// Down skips the line that's off.
|
||||
fireEvent.keyDown(newMessage, { key: 'ArrowDown' });
|
||||
|
||||
+36
-1
@@ -1,4 +1,4 @@
|
||||
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||
import { addDays, differenceInCalendarDays, isSameDay, parseISO, startOfDay, subMilliseconds } from "date-fns";
|
||||
import { parseDuration } from "@/lib/calendar-event-normalization";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone";
|
||||
@@ -229,14 +229,49 @@ export function buildTimedFullDayWeekSegments(events: CalendarEvent[], weekDays:
|
||||
return packWeekSegments(rawSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* The minutes the unsaved event being created covers on `day` in the week
|
||||
* and day views, or null when it isn't a timed event starting that day. An
|
||||
* end at or before the start is midnight.
|
||||
*/
|
||||
export function pendingTimedMinutes(
|
||||
preview: { start: Date; end: Date; allDay?: boolean } | null | undefined,
|
||||
day: Date,
|
||||
): { startMinutes: number; endMinutes: number } | null {
|
||||
if (!preview || preview.allDay || !isSameDay(preview.start, day)) return null;
|
||||
const startMinutes = preview.start.getHours() * 60 + preview.start.getMinutes();
|
||||
let endMinutes = isSameDay(preview.end, day) ? preview.end.getHours() * 60 + preview.end.getMinutes() : 1440;
|
||||
if (endMinutes <= startMinutes) endMinutes = 1440;
|
||||
return { startMinutes, endMinutes: Math.max(startMinutes + 15, endMinutes) };
|
||||
}
|
||||
|
||||
/** The id the unsaved event being created goes by in a day's layout. */
|
||||
export const PENDING_LAYOUT_ID = "__pending-event__";
|
||||
|
||||
/**
|
||||
* A day's timed events in columns: each group of overlapping events shares
|
||||
* the day's width. `pending` is the new event being created (its minutes on
|
||||
* that day); it takes a column like a saved one, with the id
|
||||
* PENDING_LAYOUT_ID, so it doesn't cover the events it overlaps.
|
||||
*/
|
||||
export function layoutOverlappingEvents(
|
||||
events: CalendarEvent[],
|
||||
day: Date,
|
||||
pending?: { startMinutes: number; endMinutes: number } | null,
|
||||
): TimedEventLayout[] {
|
||||
const layoutInputs = events.flatMap((event) => {
|
||||
const bounds = getTimedEventBoundsForDay(event, day);
|
||||
return bounds ? [{ event, ...bounds }] : [];
|
||||
});
|
||||
if (pending) {
|
||||
layoutInputs.push({
|
||||
event: { id: PENDING_LAYOUT_ID } as CalendarEvent,
|
||||
startMinutes: pending.startMinutes,
|
||||
endMinutes: Math.max(pending.startMinutes + 15, pending.endMinutes),
|
||||
continuesBefore: false,
|
||||
continuesAfter: false,
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = layoutInputs.sort((a, b) => {
|
||||
const diff = a.startMinutes - b.startMinutes;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { askInWindow, dialogWindowsAvailable, messageRuns, readDialogTheme } from '../dialog-window';
|
||||
import type { BulwarkDesktopBridge, DesktopDialogWindowRequest } from '../types';
|
||||
|
||||
function setBridge(dialogs: Record<string, unknown> | undefined) {
|
||||
window.bulwarkDesktop = (dialogs ? { version: 1, platform: 'win32', dialogs } : undefined) as BulwarkDesktopBridge | undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBridge(undefined);
|
||||
document.documentElement.removeAttribute('style');
|
||||
document.documentElement.className = '';
|
||||
document.documentElement.removeAttribute('dir');
|
||||
});
|
||||
|
||||
describe('askInWindow', () => {
|
||||
it('is not there in the browser or on a shell with only the OS message box', () => {
|
||||
expect(dialogWindowsAvailable()).toBe(false);
|
||||
expect(askInWindow({ title: 'Empty trash?', buttons: [{ label: 'OK' }] })).toBeNull();
|
||||
setBridge({ confirm: vi.fn() });
|
||||
expect(dialogWindowsAvailable()).toBe(false);
|
||||
expect(askInWindow({ title: 'Empty trash?', buttons: [{ label: 'OK' }] })).toBeNull();
|
||||
});
|
||||
|
||||
it('sends the question with the page\'s look and hands back the answer', async () => {
|
||||
document.documentElement.style.setProperty('--color-primary', '#185aea');
|
||||
document.documentElement.style.setProperty('--color-background', 'rgb(10, 10, 10)');
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.dir = 'rtl';
|
||||
const show = vi.fn(async (_request: DesktopDialogWindowRequest) => ({ button: 1 }));
|
||||
setBridge({ confirm: vi.fn(), show, cancel: vi.fn() });
|
||||
|
||||
const asked = askInWindow({ title: 'Empty trash?', message: 'Gone for good.', buttons: [{ label: 'Cancel', variant: 'secondary' }, { label: 'Empty', variant: 'destructive' }], defaultIndex: 0 })!;
|
||||
await expect(asked.answer).resolves.toEqual({ button: 1 });
|
||||
const request = show.mock.calls[0][0];
|
||||
expect(request).toMatchObject({ title: 'Empty trash?', message: [{ text: 'Gone for good.' }], defaultIndex: 0, dir: 'rtl' });
|
||||
expect(request.id).toMatch(/^dialog-/);
|
||||
expect(request.theme?.dark).toBe(true);
|
||||
expect(request.theme?.colours?.primary).toBe('rgb(24, 90, 234)');
|
||||
expect(request.theme?.colours?.background).toBe('rgb(10, 10, 10)');
|
||||
});
|
||||
|
||||
it('takes a question back until it is answered, and only then', async () => {
|
||||
let answer: (value: unknown) => void = () => {};
|
||||
const cancel = vi.fn();
|
||||
setBridge({ confirm: vi.fn(), show: vi.fn(() => new Promise((resolve) => (answer = resolve))), cancel });
|
||||
const asked = askInWindow({ title: 'Sign out?', buttons: [{ label: 'OK' }] })!;
|
||||
asked.cancel();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
const id = cancel.mock.calls[0][0];
|
||||
expect(id).toMatch(/^dialog-/);
|
||||
answer(null);
|
||||
await expect(asked.answer).resolves.toBeNull();
|
||||
asked.cancel();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says "failed" when the shell refuses, so the page can show its own dialog', async () => {
|
||||
setBridge({ confirm: vi.fn(), show: vi.fn(async () => { throw new Error('Not a question'); }), cancel: vi.fn() });
|
||||
await expect(askInWindow({ title: 'x', buttons: [{ label: 'OK' }] })!.answer).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageRuns', () => {
|
||||
it('keeps text as it is, with ** pairs bold only where asked', () => {
|
||||
expect(messageRuns('Visit **github.com**? <b>x</b>')).toEqual([{ text: 'Visit **github.com**? <b>x</b>' }]);
|
||||
expect(messageRuns('Visit **github.com**? <b>x</b>', true)).toEqual([{ text: 'Visit ' }, { text: 'github.com', bold: true }, { text: '? <b>x</b>' }]);
|
||||
expect(messageRuns('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readDialogTheme', () => {
|
||||
it('leaves out what it can\'t read as a colour', () => {
|
||||
document.documentElement.style.setProperty('--color-foreground', 'not-a-colour');
|
||||
const theme = readDialogTheme();
|
||||
expect(theme.colours?.foreground).toBeUndefined();
|
||||
expect(theme.dark).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu';
|
||||
import { readMenu, toAccelerator } from '../native-menu';
|
||||
|
||||
// A native menu built from an HTML one shows a row's key apart from its label,
|
||||
// as the native menus' own accelerators do. The calendar's view menu came out
|
||||
// as "DayD", "WeekW": the key was read as part of the label.
|
||||
|
||||
function menuOf(children: React.ReactNode) {
|
||||
render(<div role="menu" data-testid="root">{children}</div>);
|
||||
return readMenu(screen.getByTestId('root')).items;
|
||||
}
|
||||
|
||||
describe('a row\'s key in a native menu', () => {
|
||||
it('is not part of the label, and becomes the accelerator', () => {
|
||||
const items = menuOf(
|
||||
<button type="button" role="menuitemradio" aria-checked="false">
|
||||
<span>Day</span>
|
||||
<kbd>D</kbd>
|
||||
</button>,
|
||||
);
|
||||
expect(items[0].label).toBe('Day');
|
||||
expect(items[0].accelerator).toBe('D');
|
||||
});
|
||||
|
||||
it('a menu item\'s shortcut too', () => {
|
||||
const items = menuOf(<ContextMenuItem label="Cut" shortcut="Ctrl+X" onClick={() => {}} />);
|
||||
expect(items[0]).toMatchObject({ label: 'Cut', accelerator: 'Ctrl+X' });
|
||||
});
|
||||
|
||||
it('a count is no key: it goes after the label', () => {
|
||||
const items = menuOf(<ContextMenuItem label="Notifications" badge="3" onClick={() => {}} />);
|
||||
expect(items[0].label).toBe('Notifications (3)');
|
||||
expect(items[0].accelerator).toBeUndefined();
|
||||
});
|
||||
|
||||
it('words in separate elements stay separate words', () => {
|
||||
const items = menuOf(
|
||||
<button type="button" role="menuitem">
|
||||
<span>Alice Example</span>
|
||||
<span>alice@example.net</span>
|
||||
</button>,
|
||||
);
|
||||
expect(items[0].label).toBe('Alice Example alice@example.net');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAccelerator', () => {
|
||||
it.each([
|
||||
['D', 'D'],
|
||||
['r', 'R'],
|
||||
['Shift + I', 'Shift+I'],
|
||||
['Ctrl+X', 'Ctrl+X'],
|
||||
['⌘X', 'Cmd+X'],
|
||||
['⌘⇧K', 'Cmd+Shift+K'],
|
||||
['Mod + Enter', 'CmdOrCtrl+Enter'],
|
||||
['Del', 'Delete'],
|
||||
['F2', 'F2'],
|
||||
['?', '?'],
|
||||
['#', '#'],
|
||||
['Ctrl++', 'Ctrl+Plus'],
|
||||
])('%s is %s', (hint, accelerator) => {
|
||||
expect(toAccelerator(hint)).toBe(accelerator);
|
||||
});
|
||||
|
||||
it.each(['j / ↓', '', 'Hyper+Q', 'twelve'])('%s is no single key', (hint) => {
|
||||
expect(toAccelerator(hint)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Menu rows show their keys through MenuShortcut (or ContextMenuItem's
|
||||
// shortcut), which the native menu knows. A raw <kbd> in a file with menu
|
||||
// rows is how "DayD" happened.
|
||||
describe('menu rows show keys through MenuShortcut', () => {
|
||||
const root = path.resolve(__dirname, '../../..');
|
||||
it('no raw <kbd> next to role="menuitem…" rows', () => {
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.tsx')) files.push(full);
|
||||
}
|
||||
};
|
||||
walk(path.join(root, 'components'));
|
||||
walk(path.join(root, 'app'));
|
||||
const offenders = files.filter((file) => {
|
||||
if (file.endsWith(path.join('ui', 'context-menu.tsx'))) return false;
|
||||
const source = readFileSync(file, 'utf8');
|
||||
return /role="menuitem/.test(source) && /<kbd[\s>]/.test(source);
|
||||
});
|
||||
expect(offenders.map((file) => path.relative(root, file))).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu';
|
||||
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types';
|
||||
import type { BulwarkDesktopBridge } from '../types';
|
||||
import { accentColours, applySystemAccent } from '../system-accent';
|
||||
import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands';
|
||||
import { readMenu } from '../native-menu';
|
||||
import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore';
|
||||
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences';
|
||||
import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
|
||||
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
|
||||
@@ -42,149 +40,6 @@ function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox
|
||||
);
|
||||
}
|
||||
|
||||
describe('an HTML menu shown natively', () => {
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
let answer: (id: string | null) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>((resolve) => { answer = resolve; }));
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } });
|
||||
});
|
||||
|
||||
async function openMenu(children: React.ReactNode, onClose = vi.fn()) {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={onClose}>{children}</ContextMenu>);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose };
|
||||
}
|
||||
|
||||
it('reads every kind of row, in order, with submenus', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']);
|
||||
expect(items[0].enabled).toBe(false);
|
||||
expect(items[2].enabled).toBe(false);
|
||||
expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']);
|
||||
// A disabled submenu shows, and has nothing in it.
|
||||
expect(items[5].enabled).toBe(false);
|
||||
expect(items[5].submenu).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the label without the shortcut or the hint', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items[1].label).toBe('Reply');
|
||||
});
|
||||
|
||||
it('at the pointer from a right-click, under the button from a button', async () => {
|
||||
const { at } = await openMenu(testMenu());
|
||||
expect(at).toBeUndefined();
|
||||
popup.mockClear();
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 40, top: 50, right: 90, bottom: 70 } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="One" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 });
|
||||
});
|
||||
|
||||
it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const inbox = vi.fn();
|
||||
const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox }));
|
||||
await act(async () => {
|
||||
answer(items[4].submenu![0].id!);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(inbox).toHaveBeenCalledTimes(1);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismissing it runs nothing and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const { onClose } = await openMenu(testMenu(undefined, { reply }));
|
||||
await act(async () => {
|
||||
answer(null);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the hidden copy is never visible', async () => {
|
||||
await openMenu(testMenu());
|
||||
const menu = document.body.querySelector<HTMLElement>('[role="menu"]');
|
||||
expect(menu?.style.visibility).toBe('hidden');
|
||||
});
|
||||
|
||||
it('stays HTML when something in it can\'t be shown natively', async () => {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
{testMenu(<input type="color" aria-label="Colour" />)}
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => {
|
||||
const frame = (
|
||||
<div data-plugin-slot="context-menu-email"><iframe title="plugin" /></div>
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
const { items } = await openMenu(testMenu(frame), onClose);
|
||||
expect(items.at(-1)).toMatchObject({ label: 'from_plugins' });
|
||||
expect(items.map((item) => item.label)).toContain('Reply');
|
||||
await act(async () => {
|
||||
answer(items.at(-1)!.id!);
|
||||
});
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a frame that is no plugin\'s stays HTML whole', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitem">Reply</button><iframe></iframe>';
|
||||
expect(readMenu(root).unsupported).toBe(true);
|
||||
});
|
||||
|
||||
it('a row that went while the menu was up says so, instead of doing nothing', async () => {
|
||||
const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never);
|
||||
const reply = vi.fn();
|
||||
const { items, onClose } = await openMenu(testMenu(undefined, { reply }));
|
||||
const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!;
|
||||
row.parentElement!.remove();
|
||||
await act(async () => {
|
||||
answer(items[1].id!);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
expect(warning).toHaveBeenCalledWith('pick_gone');
|
||||
warning.mockRestore();
|
||||
});
|
||||
|
||||
it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitem"><svg></svg></button>';
|
||||
expect(readMenu(root).unsupported).toBe(true);
|
||||
});
|
||||
|
||||
it('a search field over a list is left out, and what it searches stays', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<input type="search" /><button role="menuitem">Inbox</button>';
|
||||
const read = readMenu(root);
|
||||
expect(read.unsupported).toBe(false);
|
||||
expect(read.items.map((item) => item.label)).toEqual(['Inbox']);
|
||||
});
|
||||
|
||||
it('reads check and radio rows', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitemcheckbox" aria-checked="true">Show</button><button role="menuitemradio" aria-checked="false">Other</button>';
|
||||
expect(readMenu(root).items).toMatchObject([
|
||||
{ type: 'checkbox', checked: true, label: 'Show' },
|
||||
{ type: 'radio', checked: false, label: 'Other' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same menu in a browser', () => {
|
||||
it('is the HTML menu, on screen, and no bridge is asked', async () => {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>{testMenu()}</ContextMenu>);
|
||||
@@ -194,6 +49,28 @@ describe('the same menu in a browser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same menu in the desktop app', () => {
|
||||
it('is the HTML menu too, with its icons and keys, and no native menu is asked for', async () => {
|
||||
const popup = vi.fn(async () => null);
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } });
|
||||
document.documentElement.lang = 'de';
|
||||
const Icon = () => <svg data-testid="row-icon" />;
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
<ContextMenuItem icon={Icon} label="Allen antworten" onClick={() => {}} shortcut="Shift + R" />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
const row = screen.getByRole('menuitem', { name: /Allen antworten/ });
|
||||
expect(row.querySelector('[data-testid="row-icon"]')).not.toBeNull();
|
||||
// Windows' own words for the keys, not Chromium's "Umschalttaste".
|
||||
expect(row.querySelector('[data-menu-shortcut]')?.textContent).toBe('Umschalt + R');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
document.documentElement.lang = '';
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
jmapServerAllowed,
|
||||
parseCertificateRule,
|
||||
parseInstanceUrl,
|
||||
pagePolicySnapshot,
|
||||
parsePolicyValue,
|
||||
parseServerPattern,
|
||||
pluginAllowedByPolicy,
|
||||
@@ -32,7 +33,7 @@ describe('the schema', () => {
|
||||
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
|
||||
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
|
||||
'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
|
||||
'OpenWithEnabled', 'DeveloperToolsAvailability',
|
||||
'OpenWithEnabled', 'DeveloperToolsAvailability', 'RecentFilesInAttach', 'FilesDriveEnabled', 'FilesFolderSyncEnabled', 'FilesDriveLetter',
|
||||
]);
|
||||
for (const key of POLICY_KEYS) {
|
||||
const { title, description } = POLICY_SCHEMA[key];
|
||||
@@ -56,6 +57,44 @@ describe('developer tools and Open with', () => {
|
||||
expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: false, locked: true });
|
||||
expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 'maybe' } })).OpenWithEnabled).toMatchObject({ value: false, locked: true });
|
||||
});
|
||||
|
||||
it('hide recent files in the Attach menu only by force, and a value nobody can read hides them', () => {
|
||||
expect(resolvePolicies(layers({})).RecentFilesInAttach).toMatchObject({ value: true, locked: false });
|
||||
expect(resolvePolicies(layers({ recommendedMachine: { RecentFilesInAttach: 0 } })).RecentFilesInAttach).toMatchObject({ value: true, locked: false });
|
||||
expect(resolvePolicies(layers({ forcedUser: { RecentFilesInAttach: 0 } })).RecentFilesInAttach).toMatchObject({ value: false, locked: true });
|
||||
expect(resolvePolicies(layers({ forcedMachine: { RecentFilesInAttach: 'yes please' } })).RecentFilesInAttach).toMatchObject({ value: false, locked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Files on this computer', () => {
|
||||
it('tells the page the drive and folder sync are forced off, so it can show the lock', () => {
|
||||
const forced = resolvePolicies(layers({ forcedMachine: { FilesDriveEnabled: 0, FilesFolderSyncEnabled: 0, FilesDriveLetter: 'R' } }));
|
||||
const page = pagePolicySnapshot(forced);
|
||||
expect(page.FilesDriveEnabled).toMatchObject({ value: false, locked: true });
|
||||
expect(page.FilesFolderSyncEnabled).toMatchObject({ value: false, locked: true });
|
||||
// The letter comes from main with the drive's state.
|
||||
expect(page.FilesDriveLetter).toMatchObject({ value: null, locked: false });
|
||||
});
|
||||
|
||||
it('turn the drive and folder sync off only by force, and a value nobody can read leaves them off', () => {
|
||||
for (const key of ['FilesDriveEnabled', 'FilesFolderSyncEnabled'] as const) {
|
||||
expect(resolvePolicies(layers({}))[key]).toMatchObject({ value: true, locked: false });
|
||||
expect(resolvePolicies(layers({ recommendedUser: { [key]: 0 } }))[key]).toMatchObject({ value: true, locked: false });
|
||||
expect(resolvePolicies(layers({ forcedUser: { [key]: 0 } }))[key]).toMatchObject({ value: false, locked: true });
|
||||
expect(resolvePolicies(layers({ forcedMachine: { [key]: 'perhaps' } }))[key]).toMatchObject({ value: false, locked: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('takes a drive letter from D to Z or none, forced only, and a value nobody can read means no letter', () => {
|
||||
expect(parsePolicyValue('FilesDriveLetter', 'x')).toEqual({ ok: true, value: 'X' });
|
||||
expect(parsePolicyValue('FilesDriveLetter', ' Q: ')).toEqual({ ok: true, value: 'Q' });
|
||||
expect(parsePolicyValue('FilesDriveLetter', 'NONE')).toEqual({ ok: true, value: 'none' });
|
||||
for (const bad of ['C', 'A:', 'B', 'QQ', 'Q:\\', '', 4, null]) expect(parsePolicyValue('FilesDriveLetter', bad).ok, String(bad)).toBe(false);
|
||||
expect(resolvePolicies(layers({})).FilesDriveLetter).toMatchObject({ value: null, locked: false });
|
||||
expect(resolvePolicies(layers({ recommendedMachine: { FilesDriveLetter: 'R' } })).FilesDriveLetter).toMatchObject({ value: null, locked: false });
|
||||
expect(resolvePolicies(layers({ forcedMachine: { FilesDriveLetter: 'r' } })).FilesDriveLetter).toMatchObject({ value: 'R', locked: true });
|
||||
expect(resolvePolicies(layers({ forcedUser: { FilesDriveLetter: 'C' } })).FilesDriveLetter).toMatchObject({ value: 'none', locked: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence', () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_POLICY_SNAPSHOT, type PolicySnapshot } from '../policy-schema';
|
||||
import type { BulwarkDesktopBridge, DesktopRecentFilesBridge } from '../types';
|
||||
import { createElement } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { listRecentFiles, readRecentFile, recentFilesOffered, recentWhen, shortFolder, useRecentFilesOffered } from '../recent-files';
|
||||
|
||||
describe('shortFolder', () => {
|
||||
it('keeps the drive or share and the folders nearest the file, and drops the middle', () => {
|
||||
expect(shortFolder('C:\\Users\\me\\Documents')).toBe('C:\\Users\\me\\Documents');
|
||||
expect(shortFolder('C:\\Users\\me\\AppData\\Local\\Temp\\bw-shots-gnIFSO\\Documents\\Reports'))
|
||||
.toBe('C:\\…\\Temp\\bw-shots-gnIFSO\\Documents\\Reports');
|
||||
expect(shortFolder('\\\\fileserver\\departments\\Finance\\Controlling\\2026\\Quarterly\\Board'))
|
||||
.toBe('\\\\fileserver\\departments\\…\\Quarterly\\Board');
|
||||
expect(shortFolder('/home/me/projects/customer-a/2026/contracts/signed/final')).toBe('/…/customer-a/2026/contracts/signed/final');
|
||||
// One long name: nothing to drop, the line's ellipsis does the rest.
|
||||
const long = `C:\\${'x'.repeat(60)}`;
|
||||
expect(shortFolder(long)).toBe(long);
|
||||
});
|
||||
});
|
||||
|
||||
function setBridge(recentFiles?: Partial<DesktopRecentFilesBridge>, policy: Partial<PolicySnapshot> = {}) {
|
||||
const snapshot = { ...DEFAULT_POLICY_SNAPSHOT, ...policy };
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
policy: { get: () => snapshot, onChange: () => () => {} },
|
||||
...(recentFiles ? { recentFiles: recentFiles as DesktopRecentFilesBridge } : {}),
|
||||
} as BulwarkDesktopBridge;
|
||||
}
|
||||
|
||||
const off = { RecentFilesInAttach: { value: false, source: 'forced-machine', locked: true } } as Partial<PolicySnapshot>;
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
describe('whether the Attach menu lists recent files', () => {
|
||||
it('not in a browser, not under a shell without the list, not when the policy says no', () => {
|
||||
expect(recentFilesOffered()).toBe(false);
|
||||
setBridge();
|
||||
expect(recentFilesOffered()).toBe(false);
|
||||
setBridge({ list: vi.fn(), attach: vi.fn() });
|
||||
expect(recentFilesOffered()).toBe(true);
|
||||
setBridge({ list: vi.fn(), attach: vi.fn() }, off);
|
||||
expect(recentFilesOffered()).toBe(false);
|
||||
});
|
||||
|
||||
it("says no in the server's render, so the page's first render matches it", () => {
|
||||
setBridge({ list: vi.fn(), attach: vi.fn() });
|
||||
const Probe = () => createElement('span', null, useRecentFilesOffered() ? 'menu' : 'button');
|
||||
expect(renderToString(createElement(Probe))).toBe('<span>button</span>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRecentFiles', () => {
|
||||
it('gives the files, and nothing when main says no or fails', async () => {
|
||||
const file = { id: 'a', name: 'a.pdf', folder: 'C:\\Docs', path: 'C:\\Docs\\a.pdf', usedAt: 1 };
|
||||
setBridge({ list: vi.fn(async () => ({ ok: true as const, files: [file] })) });
|
||||
expect(await listRecentFiles()).toEqual([file]);
|
||||
setBridge({ list: vi.fn(async () => ({ ok: false as const, reason: 'gesture' as const })) });
|
||||
expect(await listRecentFiles()).toEqual([]);
|
||||
setBridge({ list: vi.fn(async () => { throw new Error('gone'); }) });
|
||||
expect(await listRecentFiles()).toEqual([]);
|
||||
window.bulwarkDesktop = undefined;
|
||||
expect(await listRecentFiles()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecentFile', () => {
|
||||
it('turns the bytes into a File with the name main gave', async () => {
|
||||
const attach = vi.fn(async () => ({ ok: true as const, file: { name: 'Report.docx', data: new TextEncoder().encode('hello') } }));
|
||||
setBridge({ attach });
|
||||
const result = await readRecentFile('id-1');
|
||||
expect(attach).toHaveBeenCalledWith('id-1');
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.file.name).toBe('Report.docx');
|
||||
expect(await result.file.text()).toBe('hello');
|
||||
});
|
||||
|
||||
it('says what the user can act on, and keeps quiet about a lock or a policy', async () => {
|
||||
const answer = (reason: string) => {
|
||||
setBridge({ attach: vi.fn(async () => ({ ok: false, reason }) as never) });
|
||||
return readRecentFile('x');
|
||||
};
|
||||
expect(await answer('missing')).toEqual({ ok: false, reason: 'missing' });
|
||||
expect(await answer('too-large')).toEqual({ ok: false, reason: 'too-large' });
|
||||
expect(await answer('unknown')).toEqual({ ok: false, reason: 'failed' });
|
||||
expect(await answer('gesture')).toEqual({ ok: false, reason: 'failed' });
|
||||
expect(await answer('locked')).toEqual({ ok: false, reason: 'quiet' });
|
||||
expect(await answer('policy')).toEqual({ ok: false, reason: 'quiet' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recentWhen', () => {
|
||||
const now = new Date(2026, 10, 4, 15, 0).getTime();
|
||||
it('counts minutes and hours today, calendar days this week, then weeks and months', () => {
|
||||
expect(recentWhen(now - 20_000, now, 'en')).toBe('now');
|
||||
expect(recentWhen(now - 5 * 60_000, now, 'en')).toBe('5 minutes ago');
|
||||
expect(recentWhen(now - 3 * 60 * 60_000, now, 'en')).toBe('3 hours ago');
|
||||
// Last night at 23:00, 16 hours ago: yesterday.
|
||||
expect(recentWhen(new Date(2026, 10, 3, 23, 0).getTime(), now, 'en')).toBe('yesterday');
|
||||
expect(recentWhen(new Date(2026, 10, 1, 9, 0).getTime(), now, 'en')).toBe('3 days ago');
|
||||
expect(recentWhen(now - 15 * 24 * 60 * 60_000, now, 'en')).toBe('2 weeks ago');
|
||||
expect(recentWhen(now - 70 * 24 * 60 * 60_000, now, 'en')).toBe('2 months ago');
|
||||
expect(recentWhen(now - 3 * 60 * 60_000, now, 'de')).toBe('vor 3 Stunden');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user