mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: merge title bar, pop-out windows and saving attachments to a folder
This commit is contained in:
@@ -34,6 +34,9 @@ import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt
|
||||
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
|
||||
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
|
||||
import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
|
||||
import { MainWindowOnly } from "@/components/popout/main-window-only";
|
||||
import { PopoutHost } from "@/components/popout/popout-host";
|
||||
import { WindowSyncHost } from "@/components/popout/window-sync-host";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -79,16 +82,22 @@ export default async function LocaleLayout({
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<MainWindowOnly>
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<FreeBusyAutoShare />
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
</MainWindowOnly>
|
||||
{!IS_LITE && <WindowSyncHost />}
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
<FreeBusyAutoShare />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<ExternalRecipientConfirmHost />
|
||||
<PhishingReportHost />
|
||||
<CommandPaletteHost />
|
||||
<MainWindowOnly>
|
||||
<CommandPaletteHost />
|
||||
</MainWindowOnly>
|
||||
<AccessibilityQuickPanelHost />
|
||||
<PaneCycleHost />
|
||||
<FocusRescueHost />
|
||||
@@ -99,16 +108,20 @@ export default async function LocaleLayout({
|
||||
<PluginConsentDialog />
|
||||
<PluginOAuthCallbackListener />
|
||||
<PluginAppEvents />
|
||||
<PluginPageNavigator />
|
||||
<PWAInstallPrompt />
|
||||
<AppUpdatePrompt />
|
||||
<MainWindowOnly>
|
||||
<PluginPageNavigator />
|
||||
<PWAInstallPrompt />
|
||||
<AppUpdatePrompt />
|
||||
</MainWindowOnly>
|
||||
{/* Offline mode isn't in Lite (no service worker there). */}
|
||||
{!IS_LITE && <OfflineHost />}
|
||||
{!IS_LITE && <NotificationActionsHost />}
|
||||
<DevicePolicyHost />
|
||||
<PushNotificationPrompt />
|
||||
{/* Lite is a static bundle: no server keeps an archive there. */}
|
||||
{!IS_LITE && <AccountArchiveRestorePrompt />}
|
||||
<MainWindowOnly>
|
||||
<PushNotificationPrompt />
|
||||
{/* Lite is a static bundle: no server keeps an archive there. */}
|
||||
{!IS_LITE && <AccountArchiveRestorePrompt />}
|
||||
</MainWindowOnly>
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PopoutComposeApp } from "@/components/popout/popout-compose-app";
|
||||
|
||||
// The composer in a window of its own (lib/popout). Not in Lite: scripts/lite
|
||||
// removes this tree, and "Open in new window" isn't offered there.
|
||||
export default function PopoutComposePage() {
|
||||
return <PopoutComposeApp />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PopoutMessageApp } from "@/components/popout/popout-message-app";
|
||||
|
||||
// One message in a window of its own (lib/popout). Not in Lite: scripts/lite
|
||||
// removes this tree, since a static export can't list every message id.
|
||||
export default async function PopoutMessagePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <PopoutMessageApp id={decodeURIComponent(id)} />;
|
||||
}
|
||||
@@ -509,7 +509,7 @@ export default function ProHome() {
|
||||
return (
|
||||
<EmbeddedContext.Provider value={true}>
|
||||
{/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */}
|
||||
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
|
||||
<div data-pro-shell="" className="flex flex-col h-dvh bg-background overflow-hidden pt-[var(--app-top-inset)]">
|
||||
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
|
||||
<OrgAnnouncement />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
||||
@@ -20,8 +20,11 @@ import { locales, defaultLocale } from "@/i18n/routing";
|
||||
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
|
||||
import { accessibilityBootScript } from "@/lib/accessibility-settings";
|
||||
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
|
||||
import { WindowTitleBar } from "@/components/layout/window-title-bar";
|
||||
import "../globals.css";
|
||||
import "../print.css";
|
||||
import "../desktop.css";
|
||||
import { desktopBootScript } from "@/lib/desktop/boot";
|
||||
|
||||
// This layout renders <html> and sits ABOVE the [locale] segment, so
|
||||
// next-intl's getLocale() returns the default locale here - emitting
|
||||
@@ -308,6 +311,7 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
|
||||
${desktopBootScript()}
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
@@ -330,6 +334,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<WindowTitleBar />
|
||||
{!themeColorConfigured && <ThemeColorSync />}
|
||||
<FaviconBadge />
|
||||
<ErrorReportingRoot />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* The desktop app is an app, not a page. Everything here hangs on
|
||||
* html[data-desktop], which the boot script sets when the preload bridge is
|
||||
* there (lib/desktop/boot.ts), so a browser never matches any of it.
|
||||
*
|
||||
* UI chrome is listed one by one, instead of switching selection off for the
|
||||
* whole page and listing what may stay on: a message, a contact's details or
|
||||
* an event's notes must not stop being selectable because nobody thought of
|
||||
* them. An HTML message is in its own frame; a plain-text one is in the page,
|
||||
* and the rule at the end keeps it selectable wherever it sits.
|
||||
*/
|
||||
|
||||
html[data-desktop],
|
||||
html[data-desktop] body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html[data-desktop] :is(
|
||||
button,
|
||||
summary,
|
||||
label,
|
||||
nav,
|
||||
[role="button"],
|
||||
[role="navigation"],
|
||||
[role="toolbar"],
|
||||
[role="menu"],
|
||||
[role="menubar"],
|
||||
[role="menuitem"],
|
||||
[role="menuitemcheckbox"],
|
||||
[role="menuitemradio"],
|
||||
[role="tab"],
|
||||
[role="tablist"],
|
||||
[role="listbox"],
|
||||
[role="option"],
|
||||
[role="tree"],
|
||||
[role="treeitem"],
|
||||
[role="row"],
|
||||
[data-composer-header],
|
||||
[data-mv-compose-titlebar],
|
||||
[data-window-title-bar]
|
||||
) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* A field inside a label or a row is still a field. */
|
||||
html[data-desktop] :is(input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
html[data-desktop] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"], [contenteditable="plaintext-only"]) {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* No dragging an icon or a link by accident. What the app means to drag says so. */
|
||||
html[data-desktop] :is(img, a, svg) {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
html[data-desktop] [draggable="true"] {
|
||||
-webkit-user-drag: element;
|
||||
}
|
||||
|
||||
/* A plain-text message renders in the page, also inside rows and buttons. */
|
||||
html[data-desktop] [data-plugin-source="email-body"],
|
||||
html[data-desktop] [data-plugin-source="email-body"] * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
html[data-desktop] [data-plugin-source="email-body"] a {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -568,6 +568,25 @@ optgroup {
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/*
|
||||
* The top of the page in an installed app without a native title bar. 0
|
||||
* without one: then --app-top-inset is exactly env(safe-area-inset-top), what
|
||||
* the page roots used before. env() and not the display-mode media query,
|
||||
* which is false in Electron with titleBarOverlay. Set at the root, so it
|
||||
* holds from the first paint, for the boot skeletons too;
|
||||
* lib/window-controls-overlay.ts keeps it up to date afterwards.
|
||||
*/
|
||||
:root {
|
||||
--titlebar-inset: env(titlebar-area-height, 0px);
|
||||
--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));
|
||||
}
|
||||
|
||||
/* components/layout/window-title-bar.tsx: what the window is dragged by. */
|
||||
.window-title-bar {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
+5
-1
@@ -35,7 +35,8 @@ type WebAppShareTarget = {
|
||||
};
|
||||
};
|
||||
|
||||
type ExtendedManifest = MetadataRoute.Manifest & {
|
||||
type ExtendedManifest = Omit<MetadataRoute.Manifest, "display_override"> & {
|
||||
display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">;
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
file_handlers?: WebAppFileHandler[];
|
||||
share_target?: WebAppShareTarget;
|
||||
@@ -124,6 +125,9 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
start_url: withBase("/"),
|
||||
scope: withBase("/"),
|
||||
display: "standalone",
|
||||
// The page draws up to the top of the window, next to the native controls
|
||||
// (components/layout/window-title-bar.tsx). `display` is the fallback.
|
||||
display_override: ["window-controls-overlay", "standalone"],
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
background_color: backgroundColor,
|
||||
|
||||
@@ -263,7 +263,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
const backHref = location.detail ? adminPath(location.page) : '/admin';
|
||||
return (
|
||||
<AdminShellContext.Provider value={contextValue}>
|
||||
<div className="flex h-dvh flex-col bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex h-dvh flex-col bg-background pt-[var(--app-top-inset)]">
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
|
||||
{showIndex ? (
|
||||
<a
|
||||
@@ -361,7 +361,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
|
||||
return (
|
||||
<AdminShellContext.Provider value={contextValue}>
|
||||
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex h-dvh bg-background pt-[var(--app-top-inset)]">
|
||||
{/* The same rail as mail and settings, with Admin as the current app. */}
|
||||
<div className="flex w-14 shrink-0 flex-col bg-secondary" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||
<NavigationRail collapsed adminActive />
|
||||
|
||||
@@ -171,7 +171,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
|
||||
<SkeletonRegion
|
||||
surface="admin"
|
||||
label={label}
|
||||
className={cn("h-dvh bg-background pt-[env(safe-area-inset-top)]", className)}
|
||||
className={cn("h-dvh bg-background pt-[var(--app-top-inset)]", className)}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:flex h-full">
|
||||
|
||||
@@ -109,7 +109,7 @@ export function ProFrameSkeleton({
|
||||
<SkeletonRegion
|
||||
surface="pro"
|
||||
label={label}
|
||||
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]", className)}
|
||||
className={cn("flex flex-col h-dvh bg-background overflow-hidden pt-[var(--app-top-inset)]", className)}
|
||||
>
|
||||
<div data-pro-shell="" className="flex flex-1 overflow-hidden">
|
||||
<RailSkeleton search className="flex" />
|
||||
|
||||
@@ -294,7 +294,7 @@ export function AppShellSkeleton({
|
||||
<SkeletonRegion
|
||||
surface={surface}
|
||||
label={label}
|
||||
className="flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
|
||||
className="flex flex-col overflow-hidden pt-[var(--app-top-inset)] h-dvh bg-background lg:mv:bg-[var(--mv-canvas)] boot-out:hidden boot-pro-frame:hidden"
|
||||
>
|
||||
<MvTopBarSkeleton search={mvSearch} menu={mvMenu} className="hidden lg:mv:flex" />
|
||||
<div className={cn("flex flex-1 min-h-0 overflow-hidden", rowClassName)}>
|
||||
|
||||
@@ -2163,7 +2163,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
@@ -2221,7 +2221,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
|
||||
!isResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full"
|
||||
)
|
||||
@@ -2556,7 +2556,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editTask ? t("tasks.edit") : t("tasks.create")}
|
||||
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"}
|
||||
className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"}
|
||||
>
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
|
||||
@@ -288,7 +288,7 @@ export function EventModal({
|
||||
const isPaneScoped = useIsPaneScoped();
|
||||
const mobileRootClass = isPaneScoped
|
||||
? "absolute inset-0 z-40 flex flex-col bg-background"
|
||||
: "fixed inset-0 z-50 flex flex-col bg-background";
|
||||
: "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]";
|
||||
const isEdit = !!event;
|
||||
const formatEventDate = useFormatEventDate();
|
||||
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
|
||||
|
||||
@@ -326,7 +326,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
|
||||
aria-label={t("title")}
|
||||
className={cn(
|
||||
"w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl",
|
||||
"max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]",
|
||||
"max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[var(--app-top-inset)]",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1209,7 +1209,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
@@ -1265,7 +1265,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
|
||||
"transform transition-[transform,visibility] duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import en from '@/locales/en/common.json';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// Moving a draft to its own window, and a composer that is all its window
|
||||
// shows. Real composer, stores and JMAP draft payloads over a synthetic server.
|
||||
vi.unmock('next-intl');
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => null }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
type Props = React.ComponentProps<typeof EmailComposer>;
|
||||
type Draft = { subject: string };
|
||||
|
||||
const original = { auth: useAuthStore.getState(), identity: useIdentityStore.getState(), settings: useSettingsStore.getState() };
|
||||
let client: JMAPClient;
|
||||
let creations: Draft[];
|
||||
let rejectSave: boolean;
|
||||
let deferSave: boolean;
|
||||
let finishSave: (() => void) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
creations = []; rejectSave = false; deferSave = false; finishSave = undefined;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { owner: { name: 'me@example.test', isPersonal: true, accountCapabilities: {} } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'owner' },
|
||||
apiUrl: 'https://synthetic.example.test/api', uploadUrl: 'https://synthetic.example.test/upload/{accountId}',
|
||||
downloadUrl: 'https://synthetic.example.test/download/{accountId}/{blobId}/{name}', eventSourceUrl: '',
|
||||
}))));
|
||||
client = new JMAPClient('https://synthetic.example.test', 'me@example.test', 'synthetic-password');
|
||||
await client.connect();
|
||||
vi.spyOn(client, 'ping').mockResolvedValue(undefined);
|
||||
vi.mocked(fetch).mockImplementation(async (_url, init) => {
|
||||
const { methodCalls } = JSON.parse(String(init?.body)) as {
|
||||
methodCalls: Array<[string, { create?: Record<string, Draft>; ids?: string[]; destroy?: string[] }, string]>;
|
||||
};
|
||||
const methodResponses = [];
|
||||
for (const [method, args, id] of methodCalls) {
|
||||
if (method === 'Mailbox/get') methodResponses.push([method, { list: [{ id: 'drafts', role: 'drafts' }] }, id]);
|
||||
else if (method === 'Email/set' && args.create) {
|
||||
const created: Record<string, { id: string }> = {};
|
||||
for (const [key, draft] of Object.entries(args.create)) {
|
||||
creations.push(draft);
|
||||
if (deferSave) await new Promise<void>((resolve) => { finishSave = resolve; });
|
||||
created[key] = { id: `saved-${creations.length}` };
|
||||
}
|
||||
methodResponses.push([method, rejectSave
|
||||
? { notCreated: { draft: { type: 'serverFail', description: 'Synthetic refusal' } } }
|
||||
: { created }, id]);
|
||||
} else if (method === 'Email/get') methodResponses.push([method, { list: (args.ids ?? []).map((draftId) => ({ id: draftId, attachments: [] })) }, id]);
|
||||
else if (method === 'Email/set') methodResponses.push([method, { destroyed: args.destroy ?? [] }, id]);
|
||||
else methodResponses.push([method, {}, id]);
|
||||
}
|
||||
return new Response(JSON.stringify({ methodResponses }));
|
||||
});
|
||||
useAuthStore.setState({ client, activeAccountId: 'login', isAuthenticated: true, isLoading: false,
|
||||
getClientForAccount: (id) => id === 'login' ? client : undefined,
|
||||
getAllConnectedClients: () => new Map([['login', client]]),
|
||||
});
|
||||
useIdentityStore.setState({ identities: [{ id: 'me', email: 'me@example.test', name: 'Me', mayDelete: false } as never] });
|
||||
useSettingsStore.setState({ defaultSendDelay: 0 } as never);
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
client.disconnect();
|
||||
useAuthStore.setState(original.auth);
|
||||
useIdentityStore.setState(original.identity);
|
||||
useSettingsStore.setState(original.settings);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount(props: Partial<Props>) {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
|
||||
<EmailComposer mode="compose" onClose={vi.fn()} {...props} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const subjectField = () => screen.getByPlaceholderText(en.email_composer.subject_placeholder);
|
||||
|
||||
function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } {
|
||||
const handed: ComposerDraftData[] = [];
|
||||
const window = {
|
||||
handed,
|
||||
cancelled: 0,
|
||||
handOver: async (data: ComposerDraftData) => { handed.push(data); return shown; },
|
||||
cancel: () => { window.cancelled += 1; },
|
||||
};
|
||||
return window;
|
||||
}
|
||||
|
||||
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
|
||||
|
||||
describe('"Open in new window" on a composer', () => {
|
||||
it('is not there without the host offering it: browsers stay as they are', () => {
|
||||
mount({});
|
||||
expect(screen.queryByTestId('composer-open-in-window')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the window inside the click, before any save', async () => {
|
||||
const popOut = vi.fn(() => target());
|
||||
mount({ popOut });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
// Synchronously: a browser only opens a window for a user action.
|
||||
expect(popOut).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(popOut.mock.results[0].value.handed).toHaveLength(1));
|
||||
});
|
||||
|
||||
it('saves the text, hands over the same draft id, and closes here', async () => {
|
||||
const window = target();
|
||||
const onClose = vi.fn();
|
||||
const onSaveState = vi.fn();
|
||||
mount({ popOut: () => window, onClose, onSaveState });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Moves along' } });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(window.handed).toHaveLength(1));
|
||||
const data = window.handed[0];
|
||||
expect(data.subject).toBe('Moves along');
|
||||
expect(creations.map((draft) => draft.subject)).toEqual(['Moves along']);
|
||||
expect(data.draftId).toBe('saved-1');
|
||||
expect(data.unsaved).toBe(false);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
// Not left behind in the host's "continue draft" slot.
|
||||
cleanup();
|
||||
expect(onSaveState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a draft that is already saved under its id, without saving again', async () => {
|
||||
const window = target();
|
||||
mount({
|
||||
popOut: () => window,
|
||||
initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'existing' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(window.handed).toHaveLength(1));
|
||||
expect(window.handed[0].draftId).toBe('existing');
|
||||
expect(creations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the composer and calls it off when the other window never shows it', async () => {
|
||||
const window = target(false);
|
||||
const onClose = vi.fn();
|
||||
mount({ popOut: () => window, onClose });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Still here' } });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(window.cancelled).toBe(1));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect((subjectField() as HTMLInputElement).value).toBe('Still here');
|
||||
expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.window_not_ready);
|
||||
});
|
||||
|
||||
it('does not close here before the other window shows it, also after a failed save', async () => {
|
||||
rejectSave = true;
|
||||
let show: (shown: boolean) => void = () => {};
|
||||
const handed: ComposerDraftData[] = [];
|
||||
const onClose = vi.fn();
|
||||
mount({
|
||||
popOut: () => ({ cancel: vi.fn(), handOver: (data) => { handed.push(data); return new Promise((resolve) => { show = resolve; }); } }),
|
||||
onClose,
|
||||
});
|
||||
fireEvent.change(subjectField(), { target: { value: 'Refused' } });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(handed).toHaveLength(1));
|
||||
expect(handed[0].unsaved).toBe(true);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
show(true);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('takes the send options along', async () => {
|
||||
const window = target();
|
||||
mount({ popOut: () => window, initialData: { ...DRAFT, requestReadReceipt: true, requestDsn: true, requireTls: true } });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(window.handed).toHaveLength(1));
|
||||
expect(window.handed[0]).toMatchObject({ requestReadReceipt: true, requestDsn: true, requireTls: true });
|
||||
});
|
||||
|
||||
it('stays open and says why when the browser blocks the window', async () => {
|
||||
const onClose = vi.fn();
|
||||
mount({ popOut: () => null, onClose });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Stays' } });
|
||||
fireEvent.click(screen.getByTestId('composer-open-in-window'));
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.popup_blocked));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect((subjectField() as HTMLInputElement).value).toBe('Stays');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a composer that is all its window shows', () => {
|
||||
function unload() {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
it('asks before the window closes with unsaved text, and saves meanwhile', async () => {
|
||||
const onUnloadState = vi.fn();
|
||||
mount({ ownWindow: { onUnloadState } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Not saved yet' } });
|
||||
const event = unload();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onUnloadState.mock.calls[0][0]).toMatchObject({ subject: 'Not saved yet', unsaved: true });
|
||||
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Not saved yet']));
|
||||
});
|
||||
|
||||
it('hands unsaved text to another window as it closes, with no prompt and no save of its own', () => {
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Rescued' } });
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
expect(rescueDraft).toHaveBeenCalledTimes(1);
|
||||
expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'Rescued', draftId: undefined })]);
|
||||
expect(creations).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces the version it holds when another window saves it', () => {
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
mount({
|
||||
ownWindow: { rescueDraft },
|
||||
initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'saved-before' },
|
||||
});
|
||||
fireEvent.change(subjectField(), { target: { value: 'New' } });
|
||||
unload();
|
||||
expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'New', draftId: 'saved-before' })]);
|
||||
});
|
||||
|
||||
it('asks and saves itself when no other window can take it', async () => {
|
||||
const rescueDraft = vi.fn(() => false);
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Nobody home' } });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Nobody home']));
|
||||
});
|
||||
|
||||
it('does not hand it over after a failed save: that window would likely fail too, with nobody looking', async () => {
|
||||
rejectSave = true;
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Server down' } });
|
||||
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
|
||||
await waitFor(() => expect(unload().defaultPrevented).toBe(true));
|
||||
expect(rescueDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not hand it over while the browser is offline', () => {
|
||||
const onLine = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Wi-Fi gone' } });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
expect(rescueDraft).not.toHaveBeenCalled();
|
||||
onLine.mockRestore();
|
||||
});
|
||||
|
||||
it('does not hand it over with a save running: two saves would make two drafts', async () => {
|
||||
deferSave = true;
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Saving' } });
|
||||
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
expect(rescueDraft).not.toHaveBeenCalled();
|
||||
finishSave?.();
|
||||
});
|
||||
|
||||
it('asks when the window closes during a send', async () => {
|
||||
let finish: () => void = () => {};
|
||||
const onSend = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
|
||||
const onClose = vi.fn();
|
||||
mount({
|
||||
ownWindow: {}, onSend, onClose,
|
||||
initialData: { ...DRAFT, to: 'them@example.test', subject: 'Going', body: '<p>Hello</p>' },
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled(), { timeout: 4000 });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
finish();
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('counts text handed in unsaved as unsaved: it asks, and saves it', async () => {
|
||||
const rescueDraft = vi.fn(() => false);
|
||||
mount({ ownWindow: { rescueDraft }, initialData: { ...DRAFT, subject: 'Handed in', unsaved: true } });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Handed in']));
|
||||
});
|
||||
|
||||
it('saves text handed in unsaved by itself, untouched', async () => {
|
||||
mount({ ownWindow: {}, initialData: { ...DRAFT, subject: 'Autosaved after the move', unsaved: true } });
|
||||
await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Autosaved after the move']), { timeout: 4000 });
|
||||
});
|
||||
|
||||
it('keeps the draft to itself when a plugin has a say in what is saved', async () => {
|
||||
const rescueDraft = vi.fn(() => true);
|
||||
const hook = emailHooks.onBeforeDraftAutoSave.register('test', (draft: unknown) => draft as never);
|
||||
try {
|
||||
mount({ ownWindow: { rescueDraft } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Encrypted, say' } });
|
||||
expect(unload().defaultPrevented).toBe(true);
|
||||
expect(rescueDraft).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
hook.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets an untouched composer go without a word', () => {
|
||||
mount({ ownWindow: {} });
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
expect(creations).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not ask again, or save again, once the autosave has it', async () => {
|
||||
const onDraftSaved = vi.fn();
|
||||
mount({ ownWindow: { onDraftSaved } });
|
||||
fireEvent.change(subjectField(), { target: { value: 'Autosaved' } });
|
||||
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
|
||||
await waitFor(() => expect(onDraftSaved).toHaveBeenCalled());
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
expect(creations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps today\'s behaviour outside a window of its own: no prompt', () => {
|
||||
mount({});
|
||||
fireEvent.change(subjectField(), { target: { value: 'In the main window' } });
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('closes itself after the send, and writes no draft on the way out', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSend = vi.fn(async () => {});
|
||||
mount({
|
||||
ownWindow: {}, onSend, onClose,
|
||||
initialData: { to: 'them@example.test', cc: '', bcc: '', subject: 'Off it goes', body: '<p>Hello</p>', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null },
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled(), { timeout: 4000 });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
const saved = creations.length;
|
||||
expect(unload().defaultPrevented).toBe(false);
|
||||
expect(creations).toHaveLength(saved);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { ToastContainer } from '@/components/ui/toast';
|
||||
import en from '@/locales/en/common.json';
|
||||
import { setDownloadGuard } from '@/lib/download-guard';
|
||||
|
||||
// Keep the actual viewer handler, translations, JMAP routing/auth/retries and
|
||||
// JSZip. Stub unrelated viewer hosts, as in the other viewer component tests.
|
||||
@@ -312,3 +313,147 @@ describe('Download all attachments', () => {
|
||||
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save all attachments to a folder', () => {
|
||||
/** A folder as the picker hands it out; `failOn` makes that file's write fail. */
|
||||
function fakeFolder(failOn?: string) {
|
||||
const files = new Map<string, Blob | Uint8Array>();
|
||||
return {
|
||||
files,
|
||||
name: 'Downloads',
|
||||
async getFileHandle(name: string, options?: { create?: boolean }) {
|
||||
if (!files.has(name) && !options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' });
|
||||
return {
|
||||
createWritable: async () => ({
|
||||
write: async (data: Blob | Uint8Array) => {
|
||||
if (name === failOn) throw new Error('Disk full');
|
||||
files.set(name, data);
|
||||
},
|
||||
close: async () => {},
|
||||
abort: async () => {},
|
||||
}),
|
||||
};
|
||||
},
|
||||
async removeEntry(name: string) { files.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function stubPicker(result: () => Promise<unknown>) {
|
||||
const picker = vi.fn(result);
|
||||
(window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker = picker;
|
||||
return picker;
|
||||
}
|
||||
|
||||
const saveButton = () => screen.getByTestId('attachments-save-to-folder');
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker;
|
||||
setDownloadGuard(null);
|
||||
});
|
||||
|
||||
it('is not offered where the browser cannot ask for a folder, and the zip stays', async () => {
|
||||
await openViewer();
|
||||
expect(screen.queryByTestId('attachments-save-to-folder')).toBeNull();
|
||||
expect(screen.getAllByTitle('Download all')[0]).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps one row in a narrow pane: with two buttons, just the icons there, and always a name', async () => {
|
||||
stubPicker(async () => fakeFolder());
|
||||
await openViewer();
|
||||
const save = saveButton();
|
||||
expect(save.getAttribute('aria-label')).toBe(en.pwaWindows.save_all_to_folder);
|
||||
expect(save.querySelector('span')?.className).toContain('hidden @xl:inline');
|
||||
const download = screen.getAllByTitle('Download all')[0];
|
||||
expect(download.getAttribute('aria-label')).toBe('Download all');
|
||||
expect(download.querySelector('span')?.className).toContain('hidden @xl:inline');
|
||||
expect(screen.getAllByTestId('reader-attachments')[0].className).toContain('@container');
|
||||
});
|
||||
|
||||
it('shows the zip button as it was where there is only that one', async () => {
|
||||
await openViewer();
|
||||
const download = screen.getAllByTitle('Download all')[0];
|
||||
expect(download.querySelector('span')?.className ?? '').not.toContain('hidden');
|
||||
expect(download.textContent).toContain('Download all');
|
||||
});
|
||||
|
||||
it('writes each attachment to the folder, names the folder, and starts no zip', async () => {
|
||||
const folder = fakeFolder();
|
||||
const picker = stubPicker(async () => folder);
|
||||
const p = await openViewer();
|
||||
p.success();
|
||||
fireEvent.click(saveButton());
|
||||
expect(picker).toHaveBeenCalledWith(expect.objectContaining({ mode: 'readwrite' }));
|
||||
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
|
||||
expect.objectContaining({ type: 'success', title: 'Saved 2 attachments to Downloads.' }),
|
||||
]));
|
||||
expect([...folder.files.keys()]).toEqual(['report.txt', 'data.bin']);
|
||||
p.noDownload();
|
||||
});
|
||||
|
||||
it('asks the device policy before the picker: no save-all when downloads are blocked', async () => {
|
||||
const picker = stubPicker(async () => fakeFolder());
|
||||
const p = await openViewer();
|
||||
setDownloadGuard(() => false);
|
||||
fireEvent.click(saveButton());
|
||||
await act(async () => {});
|
||||
expect(picker).not.toHaveBeenCalled();
|
||||
expect(p.fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks again before each file: a policy turning on stops the rest and says how many went', async () => {
|
||||
const folder = fakeFolder();
|
||||
stubPicker(async () => folder);
|
||||
const p = await openViewer();
|
||||
p.success();
|
||||
let asked = 0;
|
||||
// The click, then the first file's two checks. Off from the second file on.
|
||||
setDownloadGuard(() => ++asked <= 3);
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
|
||||
expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }),
|
||||
]));
|
||||
expect([...folder.files.keys()]).toEqual(['report.txt']);
|
||||
});
|
||||
|
||||
it('does nothing when the picker is closed', async () => {
|
||||
stubPicker(async () => { throw Object.assign(new Error('closed'), { name: 'AbortError' }); });
|
||||
const p = await openViewer();
|
||||
fireEvent.click(saveButton());
|
||||
await act(async () => {});
|
||||
expect(p.fetchMock).not.toHaveBeenCalled();
|
||||
expect(useToastStore.getState().toasts).toEqual([]);
|
||||
p.noDownload();
|
||||
});
|
||||
|
||||
it('falls back to the zip when the browser refuses the picker', async () => {
|
||||
stubPicker(async () => { throw Object.assign(new Error('refused'), { name: 'SecurityError' }); });
|
||||
const p = await openViewer();
|
||||
p.success();
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 });
|
||||
await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]);
|
||||
});
|
||||
|
||||
it('says how many were saved when a write fails midway', async () => {
|
||||
const folder = fakeFolder('data.bin');
|
||||
stubPicker(async () => folder);
|
||||
const p = await openViewer();
|
||||
p.success();
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
|
||||
expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }),
|
||||
]));
|
||||
expect([...folder.files.keys()]).toEqual(['report.txt']);
|
||||
});
|
||||
|
||||
it('says so when nothing could be written', async () => {
|
||||
const folder = fakeFolder('report.txt');
|
||||
stubPicker(async () => folder);
|
||||
const p = await openViewer();
|
||||
p.success();
|
||||
fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(useToastStore.getState().toasts).toEqual([
|
||||
expect.objectContaining({ type: 'error', title: 'Could not save the attachments to Downloads.' }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,6 +166,7 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import type { RescuedDraft } from "@/lib/popout/channel";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
|
||||
/**
|
||||
@@ -257,6 +258,10 @@ export interface ComposerDraftData {
|
||||
* next save/send rebuilds the draft without them - silent data loss (#849).
|
||||
*/
|
||||
attachments?: Array<{ blobId: string; name?: string; type?: string; size: number; cid?: string; disposition?: string; sourceAccountId?: string }>;
|
||||
/** The send options picked so far; absent, the defaults. Carried to another window. */
|
||||
requestReadReceipt?: boolean;
|
||||
requestDsn?: boolean;
|
||||
requireTls?: boolean;
|
||||
/** When set, overrides the header From: and the envelope MAIL FROM, where the server allows it. */
|
||||
fromOverrideEmail?: string;
|
||||
fromOverrideName?: string;
|
||||
@@ -289,6 +294,13 @@ export interface ComposerDraftData {
|
||||
unsaved?: boolean;
|
||||
}
|
||||
|
||||
/** The window a draft moves to; see EmailComposerProps.popOut. */
|
||||
export interface ComposerPopOut {
|
||||
/** True once the composer is on screen there. Until then this one keeps the text. */
|
||||
handOver(data: ComposerDraftData): Promise<boolean>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
/**
|
||||
* Submits the message. Resolves `false` when the host sent nothing (it has
|
||||
@@ -352,6 +364,29 @@ interface EmailComposerProps {
|
||||
*/
|
||||
onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void;
|
||||
onSaveState?: (data: ComposerDraftData) => void;
|
||||
/**
|
||||
* Offers "Open in new window". Called in the click, since a browser only
|
||||
* opens a window for a user action: the host opens it and answers with what
|
||||
* takes the draft, or null when no window opened. The composer then saves,
|
||||
* hands the draft over under the same draft id, and closes here.
|
||||
*/
|
||||
popOut?: () => ComposerPopOut | null;
|
||||
/**
|
||||
* This composer is all a window shows (lib/popout). It closes itself once a
|
||||
* message has gone. Closing the window with unsaved text hands the draft to
|
||||
* another window to save, or asks first when there is none.
|
||||
*/
|
||||
ownWindow?: {
|
||||
/** The state as the window unloads, for a reload to start from. */
|
||||
onUnloadState?: (data: ComposerDraftData) => void;
|
||||
/** A save reached the server, for other windows showing the Drafts folder. */
|
||||
onDraftSaved?: () => void;
|
||||
/**
|
||||
* Hands a draft to another window to save as this one closes: a request
|
||||
* started now wouldn't survive. False when that can't be done safely.
|
||||
*/
|
||||
rescueDraft?: (draft: RescuedDraft) => boolean;
|
||||
};
|
||||
className?: string;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
@@ -504,6 +539,8 @@ export function EmailComposer({
|
||||
requestCloseRef,
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
popOut,
|
||||
ownWindow,
|
||||
className,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
@@ -517,6 +554,7 @@ export function EmailComposer({
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
const tClassification = useTranslations('classification');
|
||||
@@ -989,11 +1027,11 @@ export function EmailComposer({
|
||||
input.trim() ? [...chips, parseRecipient(input)] : chips;
|
||||
const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false);
|
||||
const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = useState(initialData?.requestReadReceipt ?? requestReadReceiptDefault);
|
||||
// SMTP submission options, offered only when the server advertises the
|
||||
// extension (RFC 8621 §1.3 submissionExtensions).
|
||||
const [requestDsn, setRequestDsn] = useState(false);
|
||||
const [requireTls, setRequireTls] = useState(false);
|
||||
const [requestDsn, setRequestDsn] = useState(initialData?.requestDsn ?? false);
|
||||
const [requireTls, setRequireTls] = useState(initialData?.requireTls ?? false);
|
||||
// Message priority (#970): normal writes no header at all.
|
||||
const [priority, setPriority] = useState<MessagePriority>(initialData?.priority ?? 'normal');
|
||||
const priorityRef = useRef(priority);
|
||||
@@ -1797,6 +1835,9 @@ export function EmailComposer({
|
||||
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
|
||||
const initialLabelChoiceRef = useRef(classification.choice);
|
||||
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
|
||||
// Text handed in that the server doesn't have yet is unsaved work, however
|
||||
// little it differs from what this composer started with.
|
||||
if (initialData?.unsaved && changeVersionRef.current !== savedVersionRef.current) isDirtyRef.current = true;
|
||||
if (uneditedRef.current) isDirtyRef.current = false;
|
||||
// Changes only the user makes, also without typing: a picked recipient, a
|
||||
// removed attachment, a priority. Handed-in files move the count they're
|
||||
@@ -1862,37 +1903,84 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveSessionKey]);
|
||||
|
||||
const rescueRequestRef = useRef<() => RescuedDraft | null>(() => null);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
// The composer as a host needs it to bring it back: after a remount, in
|
||||
// another window. Read through refs, so a handler set up once stays current.
|
||||
const snapshotStateRef = useRef<() => ComposerDraftData>(() => { throw new Error('Not mounted'); });
|
||||
snapshotStateRef.current = () => ({
|
||||
...stateRef.current,
|
||||
draftAccountId: draftAccountIdRef.current,
|
||||
mode,
|
||||
replyTo,
|
||||
priority: priorityRef.current,
|
||||
classification: classificationRef.current.draftChoice,
|
||||
// Edits the last save didn't cover; a minimised composer with
|
||||
// none needs no save before an automatic sign-out.
|
||||
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
|
||||
...(uneditedRef.current ? { unedited: true } : {}),
|
||||
requestReadReceipt,
|
||||
requestDsn,
|
||||
requireTls,
|
||||
});
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
// Kept in the host's memory across a remount, handed-in or not. Only
|
||||
// the server stays out of it.
|
||||
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) {
|
||||
const s = stateRef.current;
|
||||
onSaveState({
|
||||
...s,
|
||||
draftAccountId: draftAccountIdRef.current,
|
||||
mode,
|
||||
replyTo,
|
||||
priority: priorityRef.current,
|
||||
classification: classificationRef.current.draftChoice,
|
||||
// Edits the last save didn't cover; a minimised composer with
|
||||
// none needs no save before an automatic sign-out.
|
||||
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
|
||||
...(uneditedRef.current ? { unedited: true } : {}),
|
||||
});
|
||||
}
|
||||
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Auto-save draft to server on page close (best-effort)
|
||||
const ownWindowRef = useRef(ownWindow);
|
||||
ownWindowRef.current = ownWindow;
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (isDirtyRef.current) {
|
||||
saveDraftRef.current();
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
const own = ownWindowRef.current;
|
||||
if (!own) {
|
||||
if (isDirtyRef.current) {
|
||||
saveDraftRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// This composer closed itself: nothing left to keep.
|
||||
if (closedRef.current) return;
|
||||
// Closing now could stop a send halfway.
|
||||
if (isSendingRef.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Sent, discarded or saved and closed.
|
||||
if (explicitCloseRef.current || sentRef.current) return;
|
||||
if (!isDirtyRef.current) return;
|
||||
const state = snapshotStateRef.current();
|
||||
own.onUnloadState?.(state);
|
||||
if (!state.unsaved) return;
|
||||
// A request started while a window closes is dropped, so another window
|
||||
// saves it, but not when:
|
||||
// - a plugin may change or stop what is saved, as it only runs here (and
|
||||
// rescueDraft says no while plugins haven't started);
|
||||
// - the last save failed or the browser is offline, so that window's
|
||||
// would likely fail too, with nobody looking;
|
||||
// - a save is running: its new id is unknown, and two saves make two drafts.
|
||||
const canHandOver = own.rescueDraft
|
||||
&& emailHooks.onBeforeDraftAutoSave.size === 0
|
||||
&& !saveFailedRef.current
|
||||
&& navigator.onLine !== false
|
||||
&& !inflightSaveRef.current;
|
||||
if (canHandOver) {
|
||||
const draft = rescueRequestRef.current();
|
||||
if (!draft || own.rescueDraft?.(draft)) return;
|
||||
}
|
||||
// Nobody can take it. The prompt keeps the window open: "stay" lets the
|
||||
// save run, and "leave" is the user's own choice.
|
||||
saveDraftRef.current();
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
@@ -2702,28 +2790,17 @@ export function EmailComposer({
|
||||
}, [previewAttachment, composerClient]);
|
||||
|
||||
// Auto-save draft functionality
|
||||
// `auto`: the debounced save while typing. Only that one may be skipped
|
||||
// by a plugin; saves on close and before send always run, or the text
|
||||
// would be lost.
|
||||
const saveDraftOnce = async (auto = false): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
const versionAtStart = changeVersionRef.current;
|
||||
const draftRecipients = () => ({
|
||||
toAddresses: expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)),
|
||||
ccAddresses: expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)),
|
||||
bccAddresses: expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)),
|
||||
});
|
||||
|
||||
const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email));
|
||||
|
||||
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await rehomeForeignBlobs();
|
||||
} catch (error) {
|
||||
// The save below then fails with blobNotFound and reports it.
|
||||
debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error);
|
||||
}
|
||||
const draftHasContent = (toAddresses: string[]) =>
|
||||
!!toAddresses.length || !!subject || !!(plainTextMode ? body.trim() : htmlToPlainText(body).trim());
|
||||
|
||||
// What a saved draft holds, as of now. No awaits: a closing window has none to spare.
|
||||
const draftContent = () => {
|
||||
// Prepare attachments for draft. cid/disposition ride along so inline
|
||||
// parts of a re-opened draft keep matching the body's cid: references.
|
||||
const uploadedAttachments = attachmentsRef.current
|
||||
@@ -2757,6 +2834,82 @@ export function EmailComposer({
|
||||
: appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled }))
|
||||
: htmlToPlainText(draftHtmlBody!, imageAltText);
|
||||
|
||||
return { uploadedAttachments, draftHtmlBody, draftTextBody };
|
||||
};
|
||||
|
||||
// Get the selected identity or primary identity
|
||||
// Generate sub-addressed email if tag is set
|
||||
const draftSender = () => {
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? fromOverrideEmail.trim()
|
||||
: identityFromEmail;
|
||||
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
return { fromEmail, fromName };
|
||||
};
|
||||
|
||||
// The save below, for another window to make as this one closes. Null with nothing to save.
|
||||
rescueRequestRef.current = () => {
|
||||
const { toAddresses, ccAddresses, bccAddresses } = draftRecipients();
|
||||
if (!draftHasContent(toAddresses)) return null;
|
||||
const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent();
|
||||
const { fromEmail, fromName } = draftSender();
|
||||
const login = loginFor(currentIdentityParts.localAccountId);
|
||||
const previousLogin = draftLocalAccountIdRef.current;
|
||||
// A version saved through another login can't be replaced from this one.
|
||||
const replaces = !previousLogin || previousLogin === login;
|
||||
return {
|
||||
localAccountId: login,
|
||||
to: toAddresses,
|
||||
subject,
|
||||
body: draftTextBody,
|
||||
cc: ccAddresses,
|
||||
bcc: bccAddresses,
|
||||
identityId: currentIdentityRawId,
|
||||
fromEmail,
|
||||
draftId: replaces ? draftIdRef.current || undefined : undefined,
|
||||
attachments: uploadedAttachments,
|
||||
fromName,
|
||||
htmlBody: draftHtmlBody,
|
||||
inReplyTo: replyThreadingHeaders?.inReplyTo,
|
||||
references: replyThreadingHeaders?.references,
|
||||
options: {
|
||||
priority,
|
||||
previousDraftAccountId: replaces ? draftAccountIdRef.current : undefined,
|
||||
classification: classificationRef.current.draftChoice,
|
||||
},
|
||||
state: snapshotStateRef.current(),
|
||||
};
|
||||
};
|
||||
|
||||
// `auto`: the debounced save while typing. Only that one may be skipped
|
||||
// by a plugin; saves on close and before send always run, or the text
|
||||
// would be lost.
|
||||
const saveDraftOnce = async (auto = false): Promise<string | null> => {
|
||||
if (!client || !composerClient) return null;
|
||||
const versionAtStart = changeVersionRef.current;
|
||||
|
||||
const { toAddresses, ccAddresses, bccAddresses } = draftRecipients();
|
||||
|
||||
if (!draftHasContent(toAddresses)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await rehomeForeignBlobs();
|
||||
} catch (error) {
|
||||
// The save below then fails with blobNotFound and reports it.
|
||||
debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error);
|
||||
}
|
||||
|
||||
const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent();
|
||||
|
||||
// Create a hash of current data to compare with last saved. Hashing the
|
||||
// composed bodies (not the raw editor body) means a signature that changed
|
||||
// under an unchanged body - switching identity, toggling the separator -
|
||||
@@ -2776,19 +2929,7 @@ export function EmailComposer({
|
||||
|
||||
setSaveStatus('saving');
|
||||
|
||||
// Get the selected identity or primary identity
|
||||
// Generate sub-addressed email if tag is set
|
||||
const identityFromEmail = currentIdentity?.email
|
||||
? subAddressTag
|
||||
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? fromOverrideEmail.trim()
|
||||
: identityFromEmail;
|
||||
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
|
||||
? (fromOverrideName.trim() || undefined)
|
||||
: (currentIdentity?.name || undefined);
|
||||
const { fromEmail, fromName } = draftSender();
|
||||
|
||||
try {
|
||||
const previousDraftId = draftIdRef.current;
|
||||
@@ -2943,6 +3084,7 @@ export function EmailComposer({
|
||||
// Only the save the user asked for is read out: autosave would say it
|
||||
// every minute, and the save before sending is followed by "Sent".
|
||||
if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
|
||||
if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.();
|
||||
return id;
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
@@ -4040,8 +4182,10 @@ export function EmailComposer({
|
||||
// Clear ref so unmount effect doesn't re-save
|
||||
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, draftAccountId: undefined, draftLocalAccountId: undefined, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode };
|
||||
// The host closes the composer from onSend or onScheduledSendCreated;
|
||||
// a send the plugin made itself reaches neither (#1207).
|
||||
if (sendHandledByPlugin && !effectiveDelayedUntil) cleanClose();
|
||||
// a send the plugin made itself reaches neither (#1207). A window of
|
||||
// its own closes only now: closing it stops whatever is still running,
|
||||
// such as removing the draft's last version above.
|
||||
if (ownWindowRef.current || (sendHandledByPlugin && !effectiveDelayedUntil)) cleanClose();
|
||||
} catch (err) {
|
||||
// Not sent, so the composer stays open and is an unsent draft again.
|
||||
explicitCloseRef.current = false;
|
||||
@@ -4093,6 +4237,7 @@ export function EmailComposer({
|
||||
const afterCloseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const emitClose = () => {
|
||||
closedRef.current = true;
|
||||
onClose?.();
|
||||
const afterClose = afterCloseRef.current;
|
||||
afterCloseRef.current = null;
|
||||
@@ -4181,6 +4326,71 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// Moves the draft to its own window. The window opens first, inside the
|
||||
// click, and waits; this window saves what the server doesn't have yet,
|
||||
// gives it the draft under the same id, and closes the composer here.
|
||||
const [isPoppingOut, setIsPoppingOut] = useState(false);
|
||||
const poppingOutRef = useRef(false);
|
||||
const handlePopOut = async () => {
|
||||
if (!popOut || poppingOutRef.current || isSendingRef.current || saveCloseAttemptRef.current) return;
|
||||
const target = popOut();
|
||||
if (!target) {
|
||||
toast.error(tWindows('popup_blocked'));
|
||||
return;
|
||||
}
|
||||
poppingOutRef.current = true;
|
||||
setIsPoppingOut(true);
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
try {
|
||||
const uploads = await waitForPendingUploads(() => attachmentsRef.current, () => false);
|
||||
if (uploads === 'failed') {
|
||||
target.cancel();
|
||||
toast.error(t('save_failed'));
|
||||
return;
|
||||
}
|
||||
if (isDirtyRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current)) {
|
||||
// A failed save doesn't stop the move: the text goes along, marked
|
||||
// unsaved, and this composer lets go only once it is on screen there.
|
||||
await saveDraftRef.current().catch(() => null);
|
||||
}
|
||||
const data = snapshotStateRef.current();
|
||||
const shown = await target.handOver({
|
||||
...data,
|
||||
draftId: draftIdRef.current ?? lastDraftIdRef.current,
|
||||
draftLocalAccountId: draftLocalAccountIdRef.current,
|
||||
});
|
||||
// Not on screen there, so this is still the only copy of the text.
|
||||
if (!shown) {
|
||||
target.cancel();
|
||||
toast.error(tWindows('window_not_ready'));
|
||||
return;
|
||||
}
|
||||
explicitCloseRef.current = true;
|
||||
sendCancelledRef.current = true;
|
||||
emitClose();
|
||||
} finally {
|
||||
poppingOutRef.current = false;
|
||||
setIsPoppingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const popOutButton = (className: string, iconClassName: string) => popOut && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); void handlePopOut(); }}
|
||||
disabled={isPoppingOut || isSending}
|
||||
className={className}
|
||||
aria-label={tWindows('open_in_window')}
|
||||
title={tWindows('open_in_window')}
|
||||
data-testid="composer-open-in-window"
|
||||
>
|
||||
<ExternalLink className={iconClassName} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleDiscardAndClose = () => {
|
||||
cancelSaveAndClose();
|
||||
sendCancelledRef.current = true;
|
||||
@@ -4322,6 +4532,8 @@ export function EmailComposer({
|
||||
<div
|
||||
ref={setComposerRootEl}
|
||||
data-testid="email-composer"
|
||||
// Edits made while the draft moves wouldn't move with it.
|
||||
inert={isPoppingOut || undefined}
|
||||
className={cn("flex h-full bg-background relative", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
// Bubble phase, in the same batch as the field's own onChange. A state
|
||||
@@ -4380,6 +4592,7 @@ export function EmailComposer({
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{popOutButton("grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", "h-4 w-4")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClose()}
|
||||
@@ -4407,6 +4620,7 @@ export function EmailComposer({
|
||||
{saveStatus === 'saving' && <Save className="w-3.5 h-3.5 animate-pulse text-muted-foreground flex-shrink-0" aria-label={t('saving')} />}
|
||||
{mvWindow.state !== "minimized" && leftPanelToggle}
|
||||
{mvWindow.state !== "minimized" && rightPanelToggle}
|
||||
{mvWindow.state !== "minimized" && popOutButton("grid h-7 w-7 place-items-center rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground", "h-4 w-4")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); mvWindow.setState(mvWindow.state === "minimized" ? "normal" : "minimized"); }}
|
||||
@@ -4507,6 +4721,7 @@ export function EmailComposer({
|
||||
</Button>
|
||||
)}
|
||||
{rightPanelToggle}
|
||||
{popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")}
|
||||
{/* Close at the top end (R4). On a phone it moves to the start, as
|
||||
the phone's mail apps have it, with Send at the end. */}
|
||||
<IconButton
|
||||
|
||||
@@ -123,6 +123,8 @@ interface EmailContextMenuProps {
|
||||
copyTargets?: CopyTargetAccount[];
|
||||
onCopyToAccount?: (accountId: string, mailboxId: string) => void;
|
||||
onBatchForwardAsAttachment?: () => void;
|
||||
/** Opens the message in its own window (single message). */
|
||||
onOpenInWindow?: () => void;
|
||||
/** Save the message as .eml, print it, show its source (single message). */
|
||||
onExport?: () => void;
|
||||
onPrint?: () => void;
|
||||
@@ -183,6 +185,7 @@ export function EmailContextMenu({
|
||||
onRescheduleScheduled,
|
||||
copyTargets,
|
||||
onCopyToAccount,
|
||||
onOpenInWindow,
|
||||
onExport,
|
||||
onPrint,
|
||||
onViewSource,
|
||||
@@ -308,6 +311,7 @@ export function EmailContextMenu({
|
||||
onRescheduleScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onCancelScheduledSend,
|
||||
onOpenInWindow,
|
||||
onExport,
|
||||
onPrint,
|
||||
onViewSource,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useEmailStore, resolveEmailBlobContext, ArchiveMailboxNotFoundError, re
|
||||
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useOpenEmailInWindow } from "@/hooks/use-popouts-available";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useListRowShape } from "@/hooks/use-list-row-shape";
|
||||
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
|
||||
@@ -177,6 +178,7 @@ export function EmailList({
|
||||
}: EmailListProps) {
|
||||
const t = useTranslations('email_list');
|
||||
const tContextMenu = useTranslations('context_menu');
|
||||
const openInWindow = useOpenEmailInWindow();
|
||||
const tSpam = useTranslations('email_viewer.spam');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tViewer = useTranslations('email_viewer');
|
||||
@@ -1360,6 +1362,7 @@ export function EmailList({
|
||||
: [contextMenuEmail!];
|
||||
await copyToAccount(emails, accountId, mailboxId);
|
||||
}}
|
||||
onOpenInWindow={openInWindow && contextMenuEmail ? () => openInWindow(contextMenuEmail) : undefined}
|
||||
// Export, print and View source follow the device policy of the row's own account.
|
||||
onExport={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => {
|
||||
const target = contextMenuEmail!;
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
MailOpen,
|
||||
Loader2,
|
||||
Printer,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
File,
|
||||
Shield,
|
||||
@@ -158,6 +159,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { openPreviewInNewWindow } from "@/lib/standalone-display";
|
||||
import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
|
||||
import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder";
|
||||
import { useDevicePolicy } from "@/hooks/use-device-policy";
|
||||
import { Watermark, useWatermarkText } from "@/components/device/watermark";
|
||||
import { watermarkForPrint } from "@/lib/watermark";
|
||||
@@ -285,6 +287,8 @@ interface EmailViewerProps {
|
||||
* minimize button; `isFullscreen` reflects the host's current state.
|
||||
*/
|
||||
onToggleFullscreen?: () => void;
|
||||
/** Opens this message in its own window; left out where that isn't on offer. */
|
||||
onOpenInWindow?: () => void;
|
||||
isFullscreen?: boolean;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -1318,6 +1322,7 @@ export function EmailViewer({
|
||||
onRescheduleScheduled,
|
||||
onCompose,
|
||||
onToggleFullscreen,
|
||||
onOpenInWindow,
|
||||
isFullscreen = false,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -1329,6 +1334,7 @@ export function EmailViewer({
|
||||
}: EmailViewerProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const archiveActionEnabled = useArchiveActionEnabled();
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tImport = useTranslations('mail_import');
|
||||
@@ -3005,10 +3011,24 @@ export function EmailViewer({
|
||||
saveBlobAs(blob, downloadName, { kind: 'attachment', subject: email });
|
||||
}, [onDownloadAttachment, email, resolveAttachmentName]);
|
||||
|
||||
// An attachment's content for "download all" and "save all": fetched
|
||||
// through the JMAP client when it is blob-backed, the already decoded bytes
|
||||
// for S/MIME-decrypted and TNEF-extracted ones. Null when it has none.
|
||||
const readAttachmentContent = useCallback(async (attachment: EffectiveAttachment, name: string): Promise<Blob | Uint8Array<ArrayBuffer> | null> => {
|
||||
if (attachment.blobId && blobClient) {
|
||||
return blobClient.fetchBlob(attachment.blobId, attachment.name || name, attachment.type, blobAccountId);
|
||||
}
|
||||
if (attachment.tnefData) return attachment.tnefData as Uint8Array<ArrayBuffer>;
|
||||
if (attachment.decryptedAttachment) {
|
||||
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||
if (bytes && bytes.byteLength > 0) return bytes as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
return null;
|
||||
}, [blobClient, blobAccountId]);
|
||||
|
||||
// Bundle every attachment of this email into a single .zip and download it.
|
||||
// Fetches blob-backed attachments through the JMAP client and reuses already
|
||||
// decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual
|
||||
// failures are skipped so a single bad blob doesn't sink the whole archive.
|
||||
// Individual failures are skipped so a single bad blob doesn't sink the
|
||||
// whole archive.
|
||||
const handleDownloadAllAttachments = useCallback(async () => {
|
||||
if (isDownloadingAll || effectiveAttachments.length === 0) return;
|
||||
if (!downloadAllowed('attachment', email)) return;
|
||||
@@ -3016,39 +3036,17 @@ export function EmailViewer({
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
// Zip entries must be unique; suffix collisions with " (n)" before the
|
||||
// extension so duplicates stay recognisable.
|
||||
const uniqueName = (raw: string): string => {
|
||||
const base = raw || 'attachment';
|
||||
if (!used.has(base)) { used.add(base); return base; }
|
||||
const dot = base.lastIndexOf('.');
|
||||
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||
const ext = dot > 0 ? base.slice(dot) : '';
|
||||
let i = 1;
|
||||
let candidate = `${stem} (${i})${ext}`;
|
||||
while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; }
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
};
|
||||
// Zip entries must be unique.
|
||||
const uniqueName = createUniqueNamer();
|
||||
|
||||
let added = 0;
|
||||
for (const attachment of effectiveAttachments) {
|
||||
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
||||
try {
|
||||
if (attachment.blobId && blobClient) {
|
||||
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
|
||||
zip.file(entryName, blob);
|
||||
const content = await readAttachmentContent(attachment, entryName);
|
||||
if (content) {
|
||||
zip.file(entryName, content);
|
||||
added++;
|
||||
} else if (attachment.tnefData) {
|
||||
zip.file(entryName, attachment.tnefData);
|
||||
added++;
|
||||
} else if (attachment.decryptedAttachment) {
|
||||
const bytes = getAttachmentContentBytes(attachment.decryptedAttachment);
|
||||
if (bytes && bytes.byteLength > 0) {
|
||||
zip.file(entryName, bytes);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip individual failures; remaining attachments still bundle.
|
||||
@@ -3071,7 +3069,43 @@ export function EmailViewer({
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]);
|
||||
}, [isDownloadingAll, effectiveAttachments, readAttachmentContent, email, t, tFiles]);
|
||||
|
||||
// Save every attachment into a folder the user picks. These writes don't
|
||||
// pass saveBlobAs, so the device policy is asked here: before the picker,
|
||||
// and before each file. A browser that refuses the picker gets the zip.
|
||||
const handleSaveAllToFolder = useCallback(async () => {
|
||||
if (isDownloadingAll || effectiveAttachments.length === 0) return;
|
||||
if (!downloadAllowed('attachment', email)) return;
|
||||
let folder: FolderHandle;
|
||||
try {
|
||||
folder = await pickFolder();
|
||||
} catch (error) {
|
||||
if (!isPickerCancelled(error)) void handleDownloadAllAttachments();
|
||||
return;
|
||||
}
|
||||
setIsDownloadingAll(true);
|
||||
try {
|
||||
const result = await saveFilesToFolder(
|
||||
folder,
|
||||
effectiveAttachments.map((attachment) => {
|
||||
const name = getAttachmentDisplayName(attachment.name, attachment.type);
|
||||
return { name, read: () => readAttachmentContent(attachment, name) };
|
||||
}),
|
||||
() => downloadAllowed('attachment', email),
|
||||
);
|
||||
if (result.saved === result.total) {
|
||||
toast.success(tWindows('saved_all', { count: result.saved, folder: result.folder }));
|
||||
} else if (result.saved > 0) {
|
||||
toast.warning(tWindows('saved_partial', { saved: result.saved, total: result.total, folder: result.folder }));
|
||||
} else if (result.stopped !== 'policy') {
|
||||
// The policy said why itself.
|
||||
toast.error(tWindows('save_failed', { folder: result.folder }));
|
||||
}
|
||||
} finally {
|
||||
setIsDownloadingAll(false);
|
||||
}
|
||||
}, [isDownloadingAll, effectiveAttachments, readAttachmentContent, handleDownloadAllAttachments, email, tWindows]);
|
||||
|
||||
// What the attachment bar and the attachment cards share.
|
||||
const attachmentCardsRef = useRef<HTMLDivElement>(null);
|
||||
@@ -3082,6 +3116,7 @@ export function EmailViewer({
|
||||
onOpen: handleEffectiveAttachmentOpen,
|
||||
onDownload: handleEffectiveAttachmentDownload,
|
||||
onDownloadAll: handleDownloadAllAttachments,
|
||||
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
|
||||
downloadingAll: isDownloadingAll,
|
||||
downloadsOff,
|
||||
// Save to Files would get around the policy: Files downloads are off too, but the copy would stay.
|
||||
@@ -4479,6 +4514,20 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{onOpenInWindow && !mvViewer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenInWindow}
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={tWindows('open_in_window')}
|
||||
aria-label={tWindows('open_in_window')}
|
||||
data-testid="viewer-open-in-window"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen toggle - hidden on mobile (already fullscreen there).
|
||||
Never overflows into the More menu: in fullscreen this button is
|
||||
the way back out, so it must stay visible. */}
|
||||
@@ -4630,7 +4679,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
inset (same treatment as the preview modals). (#936) */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-4 border-b border-border",
|
||||
isPaneScoped ? "py-3" : "pb-3 pt-[calc(0.75rem+env(safe-area-inset-top))]"
|
||||
isPaneScoped ? "py-3" : "pb-3 pt-[calc(0.75rem+var(--app-top-inset))]"
|
||||
)}>
|
||||
{moreMenuSub ? (
|
||||
<button
|
||||
@@ -4781,6 +4830,18 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
<Printer className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{onOpenInWindow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInWindow}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={tWindows('open_in_window')}
|
||||
aria-label={tWindows('open_in_window')}
|
||||
data-testid="viewer-open-in-window"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{onToggleFullscreen && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Tag,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ExternalLink,
|
||||
CalendarClock,
|
||||
CalendarX,
|
||||
Paperclip,
|
||||
@@ -65,6 +66,7 @@ export type MessageActionKey =
|
||||
| "spam"
|
||||
| "report_phishing"
|
||||
| "find_from_sender"
|
||||
| "open_window"
|
||||
| "export"
|
||||
| "print"
|
||||
| "view_source"
|
||||
@@ -115,6 +117,8 @@ export interface MessageActionOptions {
|
||||
onRescheduleScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onCancelScheduledSend?: () => void;
|
||||
/** Opens the message in its own window (installed app, desktop app). */
|
||||
onOpenInWindow?: () => void;
|
||||
/** Save the message as .eml. */
|
||||
onExport?: () => void;
|
||||
onPrint?: () => void;
|
||||
@@ -149,6 +153,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
|
||||
const tViewer = useTranslations("email_viewer");
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
const tPhishing = useTranslations("phishing_report");
|
||||
const tWindows = useTranslations("pwaWindows");
|
||||
const copyLink = useCopyLink();
|
||||
const archiveEnabled = useArchiveActionEnabled();
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
|
||||
@@ -264,6 +269,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
|
||||
: [];
|
||||
// Download/Export, Print, View source (R6: after Copy, before the end).
|
||||
const file = [
|
||||
...(o.onOpenInWindow ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] : []),
|
||||
...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []),
|
||||
...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []),
|
||||
...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []),
|
||||
|
||||
@@ -52,6 +52,8 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
|
||||
onOpen: (attachment: T) => void;
|
||||
onDownload: (attachment: T) => void;
|
||||
onDownloadAll: () => void;
|
||||
/** "Save all to a folder": left out where the browser can't ask for one. */
|
||||
onSaveAllToFolder?: () => void;
|
||||
downloadingAll: boolean;
|
||||
/**
|
||||
* The account's device policy turned downloads off (Organisation >
|
||||
@@ -91,6 +93,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
onOpen,
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
onSaveAllToFolder,
|
||||
downloadingAll,
|
||||
downloadsOff = false,
|
||||
onSaveToFiles,
|
||||
@@ -101,6 +104,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
const t = useTranslations("email_viewer");
|
||||
const tFiles = useTranslations("files");
|
||||
const tDevice = useTranslations("device_policy");
|
||||
const tWindows = useTranslations("pwaWindows");
|
||||
// Opens nothing: no preview, and downloads are off.
|
||||
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
|
||||
const openLabel = (attachment: T, name: string) =>
|
||||
@@ -238,7 +242,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
const draw = layout === "cards" ? card : tile;
|
||||
|
||||
return (
|
||||
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout} {...noMediaSavingProps(downloadsOff)}>
|
||||
<section aria-label={t("attachments")} className={cn("@container flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout} {...noMediaSavingProps(downloadsOff)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
@@ -258,10 +262,27 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
onClick={onDownloadAll}
|
||||
disabled={downloadingAll}
|
||||
title={t("download_all")}
|
||||
className="shrink-0 gap-1.5 disabled:cursor-wait"
|
||||
aria-label={t("download_all")}
|
||||
className="shrink-0 gap-1.5 whitespace-nowrap disabled:cursor-wait"
|
||||
>
|
||||
{downloadingAll ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{t("download_all")}
|
||||
{/* Just the icon in a narrow pane, so the row stays one row. */}
|
||||
<span className={onSaveAllToFolder ? "hidden @xl:inline" : undefined}>{t("download_all")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{attachments.length > 1 && !downloadsOff && onSaveAllToFolder && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSaveAllToFolder}
|
||||
disabled={downloadingAll}
|
||||
title={tWindows("save_all_to_folder")}
|
||||
aria-label={tWindows("save_all_to_folder")}
|
||||
className="shrink-0 gap-1.5 whitespace-nowrap disabled:cursor-wait"
|
||||
data-testid="attachments-save-to-folder"
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
<span className="hidden @xl:inline">{tWindows("save_all_to_folder")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "se
|
||||
const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback;
|
||||
// The navigation stays, so one app crashing doesn't strand the user in it.
|
||||
return (
|
||||
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
<div className="flex h-dvh bg-background pt-[var(--app-top-inset)]">
|
||||
{!isMobile && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0 border-e border-border">
|
||||
<NavigationRail collapsed />
|
||||
|
||||
@@ -813,7 +813,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border"
|
||||
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = fal
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="fixed inset-0 z-[100] bg-background flex flex-col"
|
||||
className="fixed inset-0 z-[100] bg-background flex flex-col pt-[var(--titlebar-inset)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border shrink-0">
|
||||
<h2 id={titleId} className="text-sm font-medium text-foreground truncate">{target.name}</h2>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
* The strip above the app in a window without a native title bar: the part
|
||||
* the user drags the window by, with the window's title in small text. The
|
||||
* native window controls sit next to it, outside the area it is given. The
|
||||
* page roots leave this row empty through --titlebar-inset.
|
||||
*
|
||||
* Renders nothing, and changes nothing on the page, without an overlay. It
|
||||
* repeats the title for sighted users only, so it stays out of the
|
||||
* accessibility tree and out of the tab order.
|
||||
*/
|
||||
export function WindowTitleBar() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyTitlebarInset(next);
|
||||
setArea(next);
|
||||
}), []);
|
||||
|
||||
const visible = area !== null;
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const read = () => setTitle(document.title);
|
||||
read();
|
||||
// Each surface sets its own title, and Next puts <title> back on navigation.
|
||||
const observer = new MutationObserver(read);
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
|
||||
if (!area) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-window-title-bar=""
|
||||
className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none"
|
||||
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,6 +150,9 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler
|
||||
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
|
||||
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
|
||||
import { mailtoComposerDraft } from "./mailto-draft";
|
||||
import { useOpenEmailInWindow, usePopoutsAvailable } from "@/hooks/use-popouts-available";
|
||||
import { openComposerWindow } from "@/lib/popout/open";
|
||||
import { onRescuedDraft, takeRescuedDraft } from "@/lib/popout/rescued-drafts";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
|
||||
@@ -2261,6 +2264,39 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// another account's message under the same id.
|
||||
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
|
||||
|
||||
const popoutsOn = usePopoutsAvailable();
|
||||
const openEmailInWindow = useOpenEmailInWindow();
|
||||
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
|
||||
// slot, once it is free.
|
||||
useEffect(() => {
|
||||
const adopt = () => {
|
||||
if (pendingDraft || showComposer) return;
|
||||
const draft = takeRescuedDraft();
|
||||
if (draft) setPendingDraft(draft);
|
||||
};
|
||||
adopt();
|
||||
return onRescuedDraft(adopt);
|
||||
}, [pendingDraft, showComposer]);
|
||||
const composerPopOut = () => {
|
||||
const target = openComposerWindow(activeAccountId);
|
||||
if (!target) return null;
|
||||
return {
|
||||
cancel: target.cancel,
|
||||
handOver: (state: ComposerDraftData) => target.handOver({
|
||||
mode: state.mode,
|
||||
replyTo: state.replyTo,
|
||||
initialData: state,
|
||||
sourceEmailId: composerSourceEmail?.id,
|
||||
sourceEmailSource: composerSource,
|
||||
composeFromAccountEmail: resolveComposeAccountEmail(
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
useAccountStore.getState().getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email,
|
||||
),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleEmailSend = async (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
@@ -5012,6 +5048,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
handleDiscardDraft(draftId, draft);
|
||||
setPendingDraft(null);
|
||||
}}
|
||||
popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -5022,7 +5059,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{/* Rail and folders are many Tab stops: jump past them (WCAG 2.4.1). */}
|
||||
<a
|
||||
href="#message-list"
|
||||
@@ -5135,7 +5172,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
isEmbedded
|
||||
? (isMobile || isTablet
|
||||
? cn(
|
||||
"absolute inset-y-0 start-0 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"absolute inset-y-0 start-0 w-72 pt-[var(--app-top-inset)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
// translate-x is physical, so RTL needs the opposite
|
||||
// sign or the drawer slides off the wrong edge.
|
||||
@@ -5146,7 +5183,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mobile/Tablet: fixed overlay
|
||||
// start-0 rather than left-0: in RTL the drawer belongs on
|
||||
// the right, matching the hamburger that opens it.
|
||||
"max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
|
||||
"max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[var(--app-top-inset)]",
|
||||
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
|
||||
!sidebarOpen && "max-lg:-translate-x-full max-lg:rtl:translate-x-full",
|
||||
// Desktop: normal flow
|
||||
@@ -5288,7 +5325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
{(!isMobile || mobileSearchOpen) && (
|
||||
<div className={cn(
|
||||
"border-b border-border bg-background",
|
||||
isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto"
|
||||
isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto pt-[var(--titlebar-inset)]"
|
||||
)}>
|
||||
{isMobile && mobileSearchOpen && (
|
||||
<div className="h-14 px-2 flex items-center border-b border-border">
|
||||
@@ -5650,7 +5687,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
isHorizontalMailLayout ? "min-h-0" : "h-full",
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
"max-md:h-full max-md:pt-[env(safe-area-inset-top)]",
|
||||
"max-md:h-full max-md:pt-[var(--app-top-inset)]",
|
||||
isMobile && mobileView !== "viewer" && "max-md:hidden",
|
||||
// Tablet/Desktop: relative. Pane-mobile instead overlays the
|
||||
// pane (absolute within the relative content row above) - the
|
||||
@@ -5835,6 +5872,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
} : undefined}
|
||||
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
|
||||
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
|
||||
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
|
||||
isFullscreen={fullscreenReading}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function NotificationSheet({ onClose }: { onClose: () => void }) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title")}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background pt-[env(safe-area-inset-top)]"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background pt-[var(--app-top-inset)]"
|
||||
>
|
||||
<NotificationPanel onClose={onClose} sheet className="flex-1 min-h-0" />
|
||||
</div>,
|
||||
|
||||
@@ -133,7 +133,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
{mvShell && (
|
||||
<MvTopBar
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
|
||||
// The composer's window: when it tells the opening window to let go, and what
|
||||
// happens on a reload. The composer itself is stubbed; its own tests cover it.
|
||||
|
||||
type ComposerProps = {
|
||||
initialData?: { draftId?: string | null; subject?: string; unsaved?: boolean } | null;
|
||||
ownWindow: { rescueDraft: (draft: RescuedDraft) => boolean };
|
||||
};
|
||||
let composer: ComposerProps | null;
|
||||
|
||||
vi.mock('@/components/email/email-composer', () => ({
|
||||
EmailComposer: (props: ComposerProps) => {
|
||||
composer = props;
|
||||
return <div data-testid="composer">{props.initialData?.subject}</div>;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>, ComposerErrorFallback: () => null }));
|
||||
vi.mock('../popout-shell', () => ({
|
||||
PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoutSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() }));
|
||||
|
||||
const { PopoutComposeApp } = await import('../popout-compose-app');
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
const PAYLOAD = { mode: 'compose' as const, initialData: { subject: 'Moving', draftId: 'old', unsaved: true } as never };
|
||||
let heard: PopoutMessage[];
|
||||
let listener: NonNullable<ReturnType<typeof openPopoutChannel>>;
|
||||
|
||||
const settle = async (ms = 10) => {
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
sessionStorage.clear();
|
||||
composer = null;
|
||||
heard = [];
|
||||
listener = openPopoutChannel((message) => heard.push(message))!;
|
||||
useAuthStore.setState({ client: {} as never, activeAccountId: 'alice' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
listener.close();
|
||||
useAuthStore.setState(original);
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
function openAt(handoffId: string) {
|
||||
window.history.replaceState(null, '', `/en/popout/compose?handoff=${handoffId}&account=alice`);
|
||||
render(<PopoutComposeApp />);
|
||||
}
|
||||
|
||||
describe('PopoutComposeApp', () => {
|
||||
it('tells the opening window to let go only with the composer on screen', async () => {
|
||||
const handoff = createComposeHandoff()!;
|
||||
openAt(handoff.id);
|
||||
await settle();
|
||||
expect(heard.some((message) => message.type === 'handoff-rendered')).toBe(false);
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
|
||||
await expect(Promise.all([handoff.complete(PAYLOAD), settle()]).then(([shown]) => shown)).resolves.toBe(true);
|
||||
expect(screen.getByTestId('composer').textContent).toBe('Moving');
|
||||
});
|
||||
|
||||
it('does not while it has no session, so the opening window keeps its copy', async () => {
|
||||
useAuthStore.setState({ client: null });
|
||||
const handoff = createComposeHandoff()!;
|
||||
openAt(handoff.id);
|
||||
await settle();
|
||||
const shown = handoff.complete(PAYLOAD, 100);
|
||||
await settle(30);
|
||||
await expect(shown).resolves.toBe(false);
|
||||
handoff.cancel();
|
||||
await settle();
|
||||
});
|
||||
|
||||
it('closes when the opening window takes the draft back, also after it was delivered', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
useAuthStore.setState({ client: null });
|
||||
const handoff = createComposeHandoff()!;
|
||||
openAt(handoff.id);
|
||||
await settle();
|
||||
void handoff.complete(PAYLOAD, 5000);
|
||||
await settle();
|
||||
expect(screen.getByTestId('composer')).toBeTruthy();
|
||||
handoff.cancel();
|
||||
await settle();
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
expect(close).toHaveBeenCalled();
|
||||
close.mockRestore();
|
||||
});
|
||||
|
||||
it('after a reload, takes up the id its text was saved under, so no second draft appears', async () => {
|
||||
rememberComposePayload('h1', PAYLOAD);
|
||||
const main = openPopoutChannel((message) => {
|
||||
if (message.type === 'rescue-query') main.post({ type: 'rescue-answer', rescueKey: message.rescueKey, draftId: 'saved-by-main' });
|
||||
})!;
|
||||
openAt('h1');
|
||||
await waitFor(() => expect(composer?.initialData?.draftId).toBe('saved-by-main'));
|
||||
expect(composer?.initialData?.unsaved).toBe(false);
|
||||
main.close();
|
||||
});
|
||||
|
||||
it('after a reload with nothing handed over, keeps what it holds', async () => {
|
||||
rememberComposePayload('h2', PAYLOAD);
|
||||
openAt('h2');
|
||||
await waitFor(() => expect(composer?.initialData?.draftId).toBe('old'), { timeout: 4000 });
|
||||
expect(composer?.initialData?.unsaved).toBe(true);
|
||||
});
|
||||
|
||||
it('names the login and the key when it hands over a closing draft', async () => {
|
||||
const main = openPopoutChannel((message) => {
|
||||
if (message.type === 'main-ping') main.post({ type: 'main-pong', windowId: 'w', accountIds: ['alice'] });
|
||||
})!;
|
||||
rememberComposePayload('h3', { mode: 'compose' });
|
||||
openAt('h3');
|
||||
await waitFor(() => expect(composer).not.toBeNull());
|
||||
await settle();
|
||||
usePluginStore.setState({ initialized: false });
|
||||
expect(composer!.ownWindow.rescueDraft({ state: {} } as RescuedDraft), 'before plugins have started').toBe(false);
|
||||
usePluginStore.setState({ initialized: true });
|
||||
expect(composer!.ownWindow.rescueDraft({ state: {} } as RescuedDraft)).toBe(true);
|
||||
await settle();
|
||||
expect(heard.find((message) => message.type === 'draft-rescue')).toMatchObject({ draft: { localAccountId: 'alice', rescueKey: 'h3' } });
|
||||
main.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
|
||||
import { createSessionOpener } from '@/lib/popout/sealed';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { PopoutHost } from '../popout-host';
|
||||
|
||||
// The main window's side of pop-outs. The next-intl mock returns the key.
|
||||
|
||||
const DRAFT: RescuedDraft = {
|
||||
localAccountId: 'alice',
|
||||
to: ['a@example.test'], subject: 'Closing', body: 'text', cc: [], bcc: [], draftId: 'old', attachments: [],
|
||||
state: { subject: 'Closing' } as never,
|
||||
};
|
||||
|
||||
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() };
|
||||
let heard: PopoutMessage[];
|
||||
let popout: NonNullable<ReturnType<typeof openPopoutChannel>>;
|
||||
let client: { createDraft: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean };
|
||||
let lockRequests: string[];
|
||||
|
||||
// Rounds, not one long wait: a message and its answer are two hops, and a busy
|
||||
// machine can run a single timer before the second hop is even queued.
|
||||
const settle = async (ms = 10) => {
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Web Locks with one holder per name, as a browser has. */
|
||||
function stubLocks() {
|
||||
const held = new Set<string>();
|
||||
lockRequests = [];
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: {
|
||||
request(name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => Promise<unknown>) {
|
||||
lockRequests.push(name);
|
||||
if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null));
|
||||
held.add(name);
|
||||
return Promise.resolve(callback({}));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
// The bridge makes pop-outs available without stubbing the display mode.
|
||||
window.bulwarkDesktop = { windows: {} };
|
||||
stubLocks();
|
||||
sessionStorage.clear();
|
||||
heard = [];
|
||||
popout = openPopoutChannel((message) => heard.push(message))!;
|
||||
client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
|
||||
useAccountStore.setState({
|
||||
accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never],
|
||||
activeAccountId: 'alice',
|
||||
});
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
isDemoMode: false,
|
||||
activeAccountId: 'alice',
|
||||
client: client as never,
|
||||
getAllConnectedClients: () => new Map([['alice', client]]) as never,
|
||||
getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) as never,
|
||||
});
|
||||
useEmailStore.setState({ refreshCurrentMailbox: vi.fn(async () => {}) as never, fetchMailboxes: vi.fn(async () => {}) as never, pendingUndoSend: null });
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
popout.close();
|
||||
delete window.bulwarkDesktop;
|
||||
useAuthStore.setState(original.auth);
|
||||
useEmailStore.setState(original.email);
|
||||
useAccountStore.setState(original.accounts);
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
|
||||
});
|
||||
|
||||
const answers = () => heard.filter((message) => message.type === 'session-answer');
|
||||
|
||||
describe('in an ordinary browser tab', () => {
|
||||
it('runs nothing: no channel and no lock', async () => {
|
||||
delete window.bulwarkDesktop;
|
||||
const opened = FakeBroadcastChannel.open.size;
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
expect(FakeBroadcastChannel.open.size).toBe(opened);
|
||||
expect(lockRequests).toEqual([]);
|
||||
popout.post({ type: 'main-ping' });
|
||||
await settle();
|
||||
expect(heard).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopoutHost', () => {
|
||||
it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
expect(lockRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('lends the session to a pop-out that asks, sealed', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({
|
||||
activeAccountId: 'alice',
|
||||
sessions: [{ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' }],
|
||||
});
|
||||
expect(JSON.stringify(heard)).not.toContain('Bearer main');
|
||||
});
|
||||
|
||||
it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing about a login it does not hold, so the window that holds it answers first', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await expect(requestWindowSession({ accountId: 'bob' }, 100)).resolves.toBeNull();
|
||||
expect(answers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('renews a token the server refused, once, in this window', async () => {
|
||||
const refresh = vi.fn(async () => 'next');
|
||||
useAuthStore.setState({ refreshAccessToken: refresh as never });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer main' }, 2000);
|
||||
expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' });
|
||||
refresh.mockClear();
|
||||
await requestWindowSession({ accountId: 'alice', rejectedAuthHeader: 'Bearer older' }, 2000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves a closing window\'s draft, replacing its version', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-rescue', draft: DRAFT });
|
||||
await settle();
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
expect(client.createDraft.mock.calls[0].slice(0, 2)).toEqual([['a@example.test'], 'Closing']);
|
||||
expect(client.createDraft.mock.calls[0][7]).toBe('old');
|
||||
});
|
||||
|
||||
it('saves it once with two main windows open', async () => {
|
||||
render(<><PopoutHost /><PopoutHost /></>);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k' } });
|
||||
await settle();
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the text for the "Continue draft" slot when the save fails, and says so', async () => {
|
||||
client.createDraft.mockRejectedValueOnce(new Error('Server down'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-rescue', draft: DRAFT });
|
||||
await settle();
|
||||
expect(useToastStore.getState().toasts.map((toast) => toast.type)).toEqual(['error']);
|
||||
expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing', unsaved: true });
|
||||
});
|
||||
|
||||
it('keeps the text too when the login is gone by then', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
useAuthStore.setState({ getClientForAccount: () => undefined });
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-rescue', draft: DRAFT });
|
||||
await settle();
|
||||
expect(takeRescuedDraft()).toMatchObject({ subject: 'Closing' });
|
||||
});
|
||||
|
||||
it('tells a reloaded pop-out which id its text was saved under', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'draft-rescue', draft: { ...DRAFT, rescueKey: 'k1' } });
|
||||
await settle();
|
||||
await expect(askRescuedDraftId('k1', 1000)).resolves.toBe('new');
|
||||
await expect(askRescuedDraftId('nobody', 50)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('takes over the send\'s outcome once, and offers Undo here', async () => {
|
||||
render(<><PopoutHost /><PopoutHost /></>);
|
||||
await settle();
|
||||
const pending = { submissionId: 's', sendAt: '2030-01-01T00:00:00Z', isSmime: false };
|
||||
popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', pendingUndoSend: pending } });
|
||||
await settle();
|
||||
expect(heard.filter((message) => message.type === 'sent-ack')).toHaveLength(1);
|
||||
expect(useEmailStore.getState().pendingUndoSend).toEqual(pending);
|
||||
expect(useToastStore.getState().toasts).toEqual([]);
|
||||
|
||||
useEmailStore.setState({ pendingUndoSend: null });
|
||||
popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice' } });
|
||||
await settle();
|
||||
expect(useToastStore.getState().toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
popout.post({ type: 'mail-changed', accountId: 'alice' });
|
||||
popout.post({ type: 'mail-changed', accountId: 'alice' });
|
||||
await settle(400);
|
||||
expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalledTimes(1);
|
||||
expect(useEmailStore.getState().fetchMailboxes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tells pop-outs when its own list changed, since no push reports that to them', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
act(() => useEmailStore.setState({ emails: [{ id: 'x' } as never] }));
|
||||
await settle(120);
|
||||
expect(heard).toContainEqual({ type: 'state-change' });
|
||||
});
|
||||
|
||||
it('tells pop-outs when an account went away', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
act(() => useAccountStore.setState({ accounts: [] }));
|
||||
await settle();
|
||||
expect(heard).toContainEqual({ type: 'account-removed', accountId: 'alice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('watchMainWindow', () => {
|
||||
it('knows a main window is there, and hands it the draft', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
const main = watchMainWindow();
|
||||
await settle();
|
||||
expect(main.alive()).toBe(true);
|
||||
expect(main.rescue(DRAFT)).toBe(true);
|
||||
await settle();
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
main.stop();
|
||||
});
|
||||
|
||||
it('won\'t hand a draft to a window that doesn\'t hold its login', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
const main = watchMainWindow();
|
||||
await settle();
|
||||
expect(main.alive('alice')).toBe(true);
|
||||
expect(main.alive('bob')).toBe(false);
|
||||
expect(main.rescue({ ...DRAFT, localAccountId: 'bob' })).toBe(false);
|
||||
await settle();
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
main.stop();
|
||||
});
|
||||
|
||||
it('knows at once when the last one closed, and takes nothing then', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
const main = watchMainWindow();
|
||||
await settle();
|
||||
await act(async () => { window.dispatchEvent(new Event('pagehide')); });
|
||||
cleanup();
|
||||
await settle();
|
||||
expect(main.alive()).toBe(false);
|
||||
expect(main.rescue(DRAFT)).toBe(false);
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
main.stop();
|
||||
});
|
||||
|
||||
it('is not fooled by a closing window that still runs long enough to hear the ping', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
const main = watchMainWindow();
|
||||
await settle();
|
||||
// Still mounted: the page is on its way out, its scripts haven't stopped.
|
||||
await act(async () => { window.dispatchEvent(new Event('pagehide')); });
|
||||
const late = openPopoutChannel(() => {})!;
|
||||
late.post({ type: 'main-ping' });
|
||||
await settle();
|
||||
late.close();
|
||||
expect(main.alive()).toBe(false);
|
||||
expect(main.rescue(DRAFT)).toBe(false);
|
||||
main.stop();
|
||||
});
|
||||
|
||||
it('takes nothing with no main window from the start', async () => {
|
||||
const main = watchMainWindow();
|
||||
await settle();
|
||||
expect(main.rescue(DRAFT)).toBe(false);
|
||||
main.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sealed answers', () => {
|
||||
it('open only for the window that asked', async () => {
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
const other = await createSessionOpener();
|
||||
const mine = await createSessionOpener();
|
||||
popout.post({ type: 'session-request', requestId: 'r', publicKey: mine.publicKey });
|
||||
await settle();
|
||||
const [answer] = answers();
|
||||
expect(answer).toBeTruthy();
|
||||
if (answer.type !== 'session-answer') throw new Error('unreachable');
|
||||
await expect(mine.open(answer.sealed)).resolves.toMatchObject({ activeAccountId: 'alice' });
|
||||
await expect(other.open(answer.sealed)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { openPopoutChannel } from '@/lib/popout/channel';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
|
||||
// The pop-out message around the view it hosts: the view is stubbed, since the
|
||||
// Pro tab's tests cover it. The next-intl mock returns the key.
|
||||
|
||||
type ViewProps = {
|
||||
emailId: string;
|
||||
clientAccountId?: string;
|
||||
accountId?: string;
|
||||
liveState?: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> } | null;
|
||||
onLoaded?: (email: Email) => void;
|
||||
onClose: () => void;
|
||||
onOpenCompose?: (data: { mode: string }) => void;
|
||||
onMailChanged?: () => void;
|
||||
};
|
||||
let view: ViewProps;
|
||||
|
||||
vi.mock('@/components/pro/pro-email-tab-body', () => ({
|
||||
ProEmailView: (props: ViewProps) => {
|
||||
view = props;
|
||||
return <div data-testid="message-view" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('../popout-shell', () => ({
|
||||
PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>,
|
||||
PopoutSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() }));
|
||||
|
||||
const { PopoutMessageApp } = await import('../popout-message-app');
|
||||
|
||||
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState() };
|
||||
const EMAIL = { id: 'm/1', subject: 'Hello', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
|
||||
let found: Array<Partial<Email>> | Error;
|
||||
let client: { getSomeEmails: ReturnType<typeof vi.fn> };
|
||||
|
||||
const settle = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&owner=group');
|
||||
found = [EMAIL];
|
||||
client = {
|
||||
getSomeEmails: vi.fn(async () => {
|
||||
if (found instanceof Error) throw found;
|
||||
return found;
|
||||
}),
|
||||
};
|
||||
useAuthStore.setState({
|
||||
client: client as never,
|
||||
activeAccountId: 'login-a',
|
||||
getClientForAccount: (id: string) => (id === 'login-b' ? client : undefined) as never,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
emails: [],
|
||||
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Old mail', role: null }] as never,
|
||||
fetchMailboxes: vi.fn(async () => {}) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useAuthStore.setState(original.auth);
|
||||
useEmailStore.setState(original.email);
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('PopoutMessageApp', () => {
|
||||
it('opens the message of the address, through its login and account', async () => {
|
||||
render(<PopoutMessageApp id="rendered-for-another-id" />);
|
||||
await settle();
|
||||
expect(view.emailId).toBe('m/1');
|
||||
expect(view.clientAccountId).toBe('login-b');
|
||||
expect(view.accountId).toBe('group');
|
||||
expect(client.getSomeEmails).toHaveBeenCalledWith(['m/1'], 'group');
|
||||
});
|
||||
|
||||
it('shows the message once the folders are in: Delete needs Trash, and the toolbar must not move', async () => {
|
||||
let finish: () => void = () => {};
|
||||
useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(() => new Promise<void>((resolve) => { finish = resolve; })) as never });
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
expect(screen.queryByTestId('message-view')).toBeNull();
|
||||
expect(screen.getByTestId('skeleton')).toBeTruthy();
|
||||
await act(async () => { finish(); });
|
||||
await settle();
|
||||
expect(screen.getByTestId('message-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the message anyway when the folders cannot be fetched', async () => {
|
||||
useEmailStore.setState({ mailboxes: [], fetchMailboxes: vi.fn(async () => { throw new Error('offline'); }) as never });
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
expect(screen.getByTestId('message-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('puts the loaded message where delete, move, star and read look for it', async () => {
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
act(() => view.onLoaded?.(EMAIL));
|
||||
expect(useEmailStore.getState().emails).toEqual([EMAIL]);
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox');
|
||||
});
|
||||
|
||||
it('says so when the message is gone', async () => {
|
||||
found = [];
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await screen.findByText('message_gone_title');
|
||||
expect(screen.queryByTestId('message-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the message on screen when the server does not answer', async () => {
|
||||
found = new TypeError('Failed to fetch');
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
expect(screen.getByTestId('message-view')).toBeTruthy();
|
||||
expect(screen.queryByText('message_gone_title')).toBeNull();
|
||||
});
|
||||
|
||||
it('checks again when another window reports a change, and shows the new flags', async () => {
|
||||
const main = openPopoutChannel(() => {})!;
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
act(() => view.onLoaded?.(EMAIL));
|
||||
found = [{ ...EMAIL, keywords: { $seen: false } }];
|
||||
main.post({ type: 'state-change' });
|
||||
await waitFor(() => expect(view.liveState?.keywords).toEqual({ $seen: false }));
|
||||
// And the next toggle starts from them.
|
||||
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: false });
|
||||
main.close();
|
||||
});
|
||||
|
||||
it('checks again when the window gets the focus', async () => {
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
found = [];
|
||||
act(() => { window.dispatchEvent(new Event('focus')); });
|
||||
await screen.findByText('message_gone_title');
|
||||
});
|
||||
|
||||
it('says where a moved message went, and keeps showing it', async () => {
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
expect(document.querySelector('[data-popout-moved]')).toBeNull();
|
||||
found = [{ ...EMAIL, mailboxIds: { archive: true } }];
|
||||
act(() => { window.dispatchEvent(new Event('focus')); });
|
||||
await waitFor(() => expect(document.querySelector('[data-popout-moved]')?.textContent).toContain('message_moved_to'));
|
||||
expect(screen.getByTestId('message-view')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('tells the main window when it changed something', async () => {
|
||||
const heard: string[] = [];
|
||||
const main = openPopoutChannel((message) => heard.push(message.type))!;
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
act(() => view.onMailChanged?.());
|
||||
await settle();
|
||||
expect(heard).toContain('mail-changed');
|
||||
main.close();
|
||||
});
|
||||
|
||||
it('closes the window when the message leaves it', async () => {
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
render(<PopoutMessageApp id="m1" />);
|
||||
await settle();
|
||||
act(() => view.onClose());
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
close.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { registerComposeFlush } from '@/lib/compose-flush-registry';
|
||||
import { SIGNED_OUT_KEY } from '@/lib/sign-out-cleanup';
|
||||
import { openPopoutChannel } from '@/lib/popout/channel';
|
||||
import { sealFor } from '@/lib/popout/sealed';
|
||||
import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store';
|
||||
import { PopoutShell } from '../popout-shell';
|
||||
|
||||
// How a pop-out's session ends. The next-intl mock returns the key.
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
let close: ReturnType<typeof vi.spyOn>;
|
||||
let mounts: number;
|
||||
|
||||
function Child() {
|
||||
useEffect(() => {
|
||||
mounts += 1;
|
||||
}, []);
|
||||
return <div data-testid="content">Typed text</div>;
|
||||
}
|
||||
|
||||
const settle = async (ms = 10) => {
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function mount(kind: 'message' | 'compose', session: 'ready' | 'no-session' = 'ready') {
|
||||
useAuthStore.setState({ startSharedSession: vi.fn(async () => session) as never, activeAccountId: 'alice' });
|
||||
render(<PopoutShell kind={kind}><Child /></PopoutShell>);
|
||||
await settle();
|
||||
}
|
||||
|
||||
function endSession() {
|
||||
act(() => { window.dispatchEvent(new Event(SHARED_SESSION_ENDED_EVENT)); });
|
||||
}
|
||||
|
||||
function signOutElsewhere() {
|
||||
act(() => { window.dispatchEvent(new StorageEvent('storage', { key: SIGNED_OUT_KEY, newValue: String(Date.now()) })); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
mounts = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useAuthStore.setState(original);
|
||||
close.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('when the session is refused', () => {
|
||||
it('stops showing a message', async () => {
|
||||
await mount('message');
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
endSession();
|
||||
expect(screen.queryByTestId('content')).toBeNull();
|
||||
expect(screen.getByText('session_ended_title')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps a composer on screen, the same one, with the text in it', async () => {
|
||||
await mount('compose');
|
||||
endSession();
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
expect(mounts).toBe(1);
|
||||
expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no session at all', () => {
|
||||
it('shows a composer that holds text, and says why it can\'t send', async () => {
|
||||
await mount('compose', 'no-session');
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no message', async () => {
|
||||
await mount('message', 'no-session');
|
||||
expect(screen.queryByTestId('content')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when another window signs everyone out', () => {
|
||||
it('saves the typing first, then closes', async () => {
|
||||
const order: string[] = [];
|
||||
const stop = registerComposeFlush(async () => { order.push('saved'); });
|
||||
close.mockImplementation(() => { order.push('closed'); });
|
||||
await mount('compose');
|
||||
signOutElsewhere();
|
||||
await settle();
|
||||
expect(order).toEqual(['saved', 'closed']);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('stays open when the typing could not be saved', async () => {
|
||||
const stop = registerComposeFlush(async () => { throw new Error('The draft was not saved'); });
|
||||
await mount('compose');
|
||||
signOutElsewhere();
|
||||
await settle();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('closes a message window', async () => {
|
||||
await mount('message');
|
||||
signOutElsewhere();
|
||||
await settle();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a login leaves the list', () => {
|
||||
it('closes when no window holds it any more', async () => {
|
||||
const main = openPopoutChannel(() => {})!;
|
||||
await mount('message');
|
||||
main.post({ type: 'account-removed', accountId: 'alice' });
|
||||
await waitFor(() => expect(close).toHaveBeenCalled(), { timeout: 3000 });
|
||||
main.close();
|
||||
});
|
||||
|
||||
it('stays when another tab still holds it: a new tab dropped a login it could not restore', async () => {
|
||||
const main = openPopoutChannel((message) => {
|
||||
if (message.type !== 'session-request') return;
|
||||
void sealFor(message.publicKey, { activeAccountId: 'alice', sessions: [{ accountId: 'alice' }] })
|
||||
.then((sealed) => main.post({ type: 'session-answer', requestId: message.requestId, sealed }));
|
||||
})!;
|
||||
await mount('message');
|
||||
main.post({ type: 'account-removed', accountId: 'alice' });
|
||||
await settle(250);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('content')).toBeTruthy();
|
||||
main.close();
|
||||
});
|
||||
|
||||
it('ignores another login', async () => {
|
||||
const main = openPopoutChannel(() => {})!;
|
||||
await mount('message');
|
||||
main.post({ type: 'account-removed', accountId: 'bob' });
|
||||
await settle(250);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
main.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { isPopoutPath } from "@/lib/popout/window-role";
|
||||
|
||||
/**
|
||||
* What belongs to the app as a whole and not to each of its windows: live
|
||||
* push streams, install and update prompts, the command palette's jumps to
|
||||
* other surfaces. A pop-out shows one message or one draft, and the main
|
||||
* window does the rest.
|
||||
*/
|
||||
export function MainWindowOnly({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return isPopoutPath(pathname ?? "") ? null : <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
|
||||
import { announceSent } from "@/lib/plugin-after-send";
|
||||
import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel";
|
||||
import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
|
||||
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
|
||||
import { closePopoutWindow } from "@/lib/popout/window-role";
|
||||
import { PopoutShell, PopoutSkeleton } from "./popout-shell";
|
||||
|
||||
/** A warning with no main window left to show it stays this long before the window goes. */
|
||||
const WARNING_READ_MS = 8000;
|
||||
|
||||
type ComposerProps = React.ComponentProps<typeof EmailComposer>;
|
||||
type SendData = Parameters<NonNullable<ComposerProps["onSend"]>>[0];
|
||||
|
||||
const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
|
||||
|
||||
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
|
||||
const RESCUE_ANSWER_MS = 2000;
|
||||
|
||||
function readLaunch(): { handoffId: string | null; accountId?: string } {
|
||||
if (typeof window === "undefined") return { handoffId: null };
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined };
|
||||
}
|
||||
|
||||
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
|
||||
function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) {
|
||||
const t = useTranslations();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`);
|
||||
|
||||
const mode = payload.initialData?.mode ?? payload.mode;
|
||||
useDocumentTitle(
|
||||
mode === "reply" ? t("email_composer.reply")
|
||||
: mode === "replyAll" ? t("email_composer.reply_all")
|
||||
: mode === "forward" ? t("email_composer.forward")
|
||||
: t("email_composer.new_message"),
|
||||
);
|
||||
|
||||
const handleSend = useCallback(async (data: SendData) => {
|
||||
if (!client) return false;
|
||||
const sendClient = data.localAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
|
||||
: client;
|
||||
const emailStore = useEmailStore.getState();
|
||||
// A send that fails goes to the composer, which stays open with the text.
|
||||
const result = await emailStore.sendEmail(
|
||||
sendClient,
|
||||
data.to,
|
||||
data.subject,
|
||||
data.body,
|
||||
data.cc,
|
||||
data.bcc,
|
||||
data.identityId,
|
||||
data.fromEmail,
|
||||
data.draftId,
|
||||
data.fromName,
|
||||
data.htmlBody,
|
||||
data.attachments,
|
||||
data.inReplyTo,
|
||||
data.references,
|
||||
data.delayedUntil,
|
||||
data.envelopeMailFrom,
|
||||
composerSendOptions(data),
|
||||
);
|
||||
announceSent(result, data);
|
||||
|
||||
if (payload.sourceEmailId && (mode === "reply" || mode === "replyAll" || mode === "forward")) {
|
||||
const keyword = mode === "forward" ? "$forwarded" : "$answered";
|
||||
try {
|
||||
await useEmailStore.getState().markEmailKeyword(client, payload.sourceEmailId, keyword, payload.sourceEmailSource ?? undefined);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to set ${keyword} keyword:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId;
|
||||
const pending = useEmailStore.getState().pendingUndoSend;
|
||||
const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined;
|
||||
const taken = await announceSentToMainWindow({
|
||||
accountId,
|
||||
scheduled: !!result.scheduled,
|
||||
queued: !!result.queued,
|
||||
filingError: !!result.filingError,
|
||||
rejectedRecipients,
|
||||
pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null,
|
||||
});
|
||||
// No main window to say so, and this is the only notice these get (#1123).
|
||||
if (!taken && (result.filingError || rejectedRecipients)) {
|
||||
if (result.filingError) toast.warning(t("email_composer.send_filing_warning"));
|
||||
if (rejectedRecipients) toast.warning(t("email_composer.send_some_recipients_rejected"), { message: rejectedRecipients, duration: WARNING_READ_MS });
|
||||
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
|
||||
}
|
||||
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
|
||||
|
||||
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
|
||||
if (!client) return;
|
||||
// Through the login the draft was saved with, and never another: the id names another message there.
|
||||
const draftClient = draft?.localAccountId
|
||||
? useAuthStore.getState().getClientForAccount(draft.localAccountId)
|
||||
: client;
|
||||
if (!draftClient) {
|
||||
toast.error(t("email_composer.discard_signed_out"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await draftClient.deleteEmail(draftId, draft?.accountId);
|
||||
} catch (error) {
|
||||
console.error("Failed to discard draft:", error);
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
announceMailChanged(useAuthStore.getState().activeAccountId);
|
||||
closePopoutWindow();
|
||||
}, []);
|
||||
|
||||
const [mainWindow] = useState(watchMainWindow);
|
||||
useEffect(() => () => mainWindow.stop(), [mainWindow]);
|
||||
|
||||
// The window that opened this one lets go of its copy now. Not without a
|
||||
// session: this one couldn't save or send the text.
|
||||
useEffect(() => {
|
||||
if (handoffId && client) acknowledgeComposeHandoff(handoffId);
|
||||
}, [handoffId, client]);
|
||||
|
||||
const ownWindow = useMemo(() => ({
|
||||
rescueDraft: (draft: RescuedDraft) => {
|
||||
// A plugin that hasn't started may yet have a say in what a draft holds,
|
||||
// and it can only have it in this window's save.
|
||||
if (!usePluginStore.getState().initialized) return false;
|
||||
return mainWindow.rescue({
|
||||
...draft,
|
||||
localAccountId: draft.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined,
|
||||
rescueKey: handoffId ?? undefined,
|
||||
});
|
||||
},
|
||||
// A reload of this window starts from where the text is now.
|
||||
onUnloadState: (state: ComposerDraftData) => {
|
||||
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
|
||||
},
|
||||
onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId),
|
||||
}), [handoffId, mainWindow, payload]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
mode={mode}
|
||||
replyTo={payload.replyTo}
|
||||
initialDraftText={payload.initialDraftText}
|
||||
initialData={payload.initialData}
|
||||
prefill={payload.prefill}
|
||||
sessionKey={sessionKey}
|
||||
sourceEmailId={payload.sourceEmailId ?? undefined}
|
||||
composeFromAccountEmail={payload.composeFromAccountEmail
|
||||
?? useAccountStore.getState().getAccountById(useAuthStore.getState().activeAccountId ?? "")?.email}
|
||||
onSend={handleSend}
|
||||
onClose={handleClose}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
ownWindow={ownWindow}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/popout/compose`: a draft handed over by another window, or a new message. */
|
||||
export function PopoutComposeApp() {
|
||||
const t = useTranslations("pwaWindows");
|
||||
const [{ handoffId, accountId }] = useState(readLaunch);
|
||||
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handoffId) return;
|
||||
let cancelled = false;
|
||||
const reloaded = rememberedComposePayload(handoffId) !== null;
|
||||
void receiveComposeHandoff(handoffId).then(async (received) => {
|
||||
if (!received) {
|
||||
// Called off or never ready: the composer stayed where it was.
|
||||
if (!cancelled) closePopoutWindow();
|
||||
return;
|
||||
}
|
||||
// Reloaded: the text may have gone to a main window on the way out, under
|
||||
// a new id. Saving under the old one would leave two drafts.
|
||||
const draftId = reloaded && received.initialData ? await askRescuedDraftId(handoffId, RESCUE_ANSWER_MS) : null;
|
||||
if (cancelled) return;
|
||||
setPayload(draftId && received.initialData
|
||||
? { ...received, initialData: { ...received.initialData, draftId, draftAccountId: undefined, unsaved: false } }
|
||||
: received);
|
||||
});
|
||||
// The opening window takes the draft back. Gone first, so that closing
|
||||
// doesn't save a copy of what that window still has.
|
||||
const stopWatching = watchHandoffCancel(handoffId, () => {
|
||||
setPayload(null);
|
||||
setTimeout(closePopoutWindow, 0);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopWatching();
|
||||
};
|
||||
}, [handoffId]);
|
||||
|
||||
return (
|
||||
<PopoutShell kind="compose" accountId={accountId}>
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />}
|
||||
</PopoutShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
|
||||
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
|
||||
import { credentialFingerprint, sealFor } from "@/lib/popout/sealed";
|
||||
import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events";
|
||||
|
||||
/** At most this often: a pop-out's autosave reports every few seconds. */
|
||||
const REFRESH_THROTTLE_MS = 1500;
|
||||
/** What the list changing in this window waits for, so a burst tells pop-outs once. */
|
||||
const LIST_CHANGE_DEBOUNCE_MS = 400;
|
||||
/** How long the claim on a one-off job stays held: longer than a message takes to reach every window. */
|
||||
const CLAIM_HOLD_MS = 15_000;
|
||||
/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */
|
||||
const RESCUE_MEMORY_MS = 60_000;
|
||||
|
||||
/** The sessions this window can lend. Empty until it is signed in, and in the demo. */
|
||||
function shareableSessions(): SharedAccountSession[] {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isAuthenticated || auth.isDemoMode) return [];
|
||||
const sessions: SharedAccountSession[] = [];
|
||||
for (const [accountId, client] of auth.getAllConnectedClients()) {
|
||||
const account = useAccountStore.getState().getAccountById(accountId);
|
||||
if (!account) continue;
|
||||
sessions.push({
|
||||
accountId,
|
||||
serverUrl: account.serverUrl,
|
||||
username: account.username,
|
||||
authMode: account.authMode,
|
||||
authHeader: client.getAuthHeader(),
|
||||
...(client.isOfflineOnly() ? { offlineOnly: true } : {}),
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `work` in one of the main windows, the first to claim `name`. Held on
|
||||
* for a while afterwards, or a slower window would find it free. A browser
|
||||
* releases it with the window, so no window keeps a lock for long (one held for
|
||||
* life keeps a page out of the back/forward cache).
|
||||
*/
|
||||
function claimOnce(name: string, work: () => void | Promise<void>): void {
|
||||
void navigator.locks?.request(name, { ifAvailable: true }, async (lock) => {
|
||||
if (!lock) return;
|
||||
await work();
|
||||
await new Promise((resolve) => setTimeout(resolve, CLAIM_HOLD_MS));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The main window's side of pop-outs: lends them its session and renews it
|
||||
* for them, passes on that mail changed, and takes over what a pop-out can't
|
||||
* say any more once it has closed. Only where pop-outs are on offer: an
|
||||
* ordinary browser tab runs none of this.
|
||||
*/
|
||||
export function PopoutHost() {
|
||||
const available = usePopoutsAvailable();
|
||||
const t = useTranslations();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) return;
|
||||
let channel: PopoutChannel | null = null;
|
||||
const windowId = newPopoutId();
|
||||
let closing = false;
|
||||
const rescues = new Map<string, Promise<string | null>>();
|
||||
|
||||
const answerSessionRequest = async (message: Extract<PopoutMessage, { type: "session-request" }>) => {
|
||||
const { accountId, rejectedFingerprint } = message;
|
||||
const auth = useAuthStore.getState();
|
||||
// Only a window holding the login answers: the first answer has to be usable.
|
||||
if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return;
|
||||
// The server refused what the pop-out holds. If that is what this window
|
||||
// holds too, renew it here, where the timers and the sign-out live.
|
||||
if (accountId && rejectedFingerprint) {
|
||||
const current = auth.getClientForAccount(accountId)?.getAuthHeader();
|
||||
if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId });
|
||||
}
|
||||
const sessions = shareableSessions();
|
||||
if (sessions.length === 0) return;
|
||||
const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions });
|
||||
channel?.post({ type: "session-answer", requestId: message.requestId, sealed });
|
||||
};
|
||||
|
||||
// List and counts, so read state and Drafts follow what a pop-out did.
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const refreshList = () => {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
const emails = useEmailStore.getState();
|
||||
void emails.refreshCurrentMailbox(client);
|
||||
void emails.fetchMailboxes(client);
|
||||
}, REFRESH_THROTTLE_MS);
|
||||
};
|
||||
|
||||
const takeOverSent = (notice: PopoutSentNotice) => {
|
||||
const translate = tRef.current;
|
||||
if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning"));
|
||||
if (notice.rejectedRecipients) {
|
||||
toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 });
|
||||
}
|
||||
// Undo lives here now: the window that sent it is closing.
|
||||
if (notice.pendingUndoSend) useEmailStore.setState({ pendingUndoSend: notice.pendingUndoSend });
|
||||
else if (!notice.queued && !notice.scheduled && !notice.filingError && !notice.rejectedRecipients) toast.success(translate("notifications.email_sent"));
|
||||
refreshList();
|
||||
};
|
||||
|
||||
const saveRescuedDraft = async (draft: RescuedDraft): Promise<string | null> => {
|
||||
const auth = useAuthStore.getState();
|
||||
const client = draft.localAccountId ? auth.getClientForAccount(draft.localAccountId) : auth.client;
|
||||
try {
|
||||
if (!client) throw new Error("No login to save through");
|
||||
const draftId = await client.createDraft(
|
||||
draft.to, draft.subject, draft.body, draft.cc, draft.bcc, draft.identityId, draft.fromEmail, draft.draftId,
|
||||
draft.attachments, draft.fromName, draft.htmlBody, draft.inReplyTo, draft.references, draft.options,
|
||||
);
|
||||
refreshList();
|
||||
return draftId;
|
||||
} catch (error) {
|
||||
// The text exists nowhere else now.
|
||||
console.error("Failed to save a closing window's draft:", error);
|
||||
keepRescuedDraft(draft.state);
|
||||
toast.error(tRef.current("email_composer.save_failed"));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const startRescue = (draft: RescuedDraft) => {
|
||||
const claim = draft.rescueKey ?? newPopoutId();
|
||||
claimOnce(`bulwark-popout-rescue:${claim}`, async () => {
|
||||
const saved = saveRescuedDraft(draft);
|
||||
if (draft.rescueKey) {
|
||||
rescues.set(draft.rescueKey, saved);
|
||||
setTimeout(() => rescues.delete(claim), RESCUE_MEMORY_MS);
|
||||
}
|
||||
await saved;
|
||||
});
|
||||
};
|
||||
|
||||
channel = openPopoutChannel((message) => {
|
||||
switch (message.type) {
|
||||
case "session-request":
|
||||
void answerSessionRequest(message);
|
||||
break;
|
||||
case "sent":
|
||||
claimOnce(`bulwark-popout-sent:${message.requestId}`, () => {
|
||||
channel?.post({ type: "sent-ack", requestId: message.requestId });
|
||||
takeOverSent(message.notice);
|
||||
});
|
||||
break;
|
||||
case "draft-rescue":
|
||||
startRescue(message.draft);
|
||||
break;
|
||||
case "rescue-query": {
|
||||
const rescue = rescues.get(message.rescueKey);
|
||||
if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId }));
|
||||
break;
|
||||
}
|
||||
case "main-ping":
|
||||
if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) });
|
||||
break;
|
||||
case "mail-changed":
|
||||
refreshList();
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (!channel) return;
|
||||
|
||||
const stopPush = subscribePushStateChanges((change) => {
|
||||
if (changesAnyType(change, ["Email", "Mailbox"])) channel?.post({ type: "state-change" });
|
||||
});
|
||||
// This window's own changes reach no push stream on the way to a pop-out.
|
||||
let listTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const stopList = useEmailStore.subscribe((state, prev) => {
|
||||
if (state.emails === prev.emails && state.selectedEmail === prev.selectedEmail) return;
|
||||
if (listTimer) clearTimeout(listTimer);
|
||||
listTimer = setTimeout(() => channel?.post({ type: "state-change" }), LIST_CHANGE_DEBOUNCE_MS);
|
||||
});
|
||||
const stopAccounts = useAccountStore.subscribe((state, prev) => {
|
||||
if (state.accounts === prev.accounts) return;
|
||||
const ids = new Set(state.accounts.map((account) => account.id));
|
||||
for (const account of prev.accounts) {
|
||||
if (!ids.has(account.id)) channel?.post({ type: "account-removed", accountId: account.id });
|
||||
}
|
||||
});
|
||||
// Hearing a ping late in closing, this window must not answer: a draft
|
||||
// handed to it now would be lost.
|
||||
const onPageHide = () => {
|
||||
closing = true;
|
||||
channel?.post({ type: "main-gone", windowId });
|
||||
};
|
||||
const onPageShow = () => {
|
||||
closing = false;
|
||||
};
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
stopPush();
|
||||
stopList();
|
||||
stopAccounts();
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
if (listTimer) clearTimeout(listTimer);
|
||||
channel?.close();
|
||||
};
|
||||
}, [available]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import type { ProComposeTabData } from "@/stores/pro-tab-store";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { announceMailChanged, openPopoutChannel } from "@/lib/popout/channel";
|
||||
import { openComposerWindow } from "@/lib/popout/open";
|
||||
import { closePopoutWindow } from "@/lib/popout/window-role";
|
||||
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
|
||||
|
||||
/** How often an open message checks that it is still there, without a main window to say so. */
|
||||
const CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The id from the address bar: a page the service worker kept for a start
|
||||
* without the network was rendered for another id.
|
||||
*/
|
||||
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } {
|
||||
if (typeof window === "undefined") return { emailId: paramId };
|
||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||
const at = segments.lastIndexOf("message");
|
||||
let emailId = paramId;
|
||||
try {
|
||||
if (at >= 0 && segments[at + 1]) emailId = decodeURIComponent(segments[at + 1]);
|
||||
} catch {
|
||||
// A malformed address: the route's own value.
|
||||
}
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined };
|
||||
}
|
||||
|
||||
type Presence = "present" | "gone";
|
||||
type LiveState = Pick<Email, "keywords" | "mailboxIds">;
|
||||
|
||||
/**
|
||||
* Whether the message still exists, and in which folders. A JMAP move keeps
|
||||
* the id, so moved mail stays readable here; only its folders change.
|
||||
*/
|
||||
function useMessagePresence(emailId: string, ownerAccountId: string | undefined): { presence: Presence; mailboxIds: string[] | null; liveState: LiveState | null } {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [presence, setPresence] = useState<Presence>("present");
|
||||
const [mailboxIds, setMailboxIds] = useState<string[] | null>(null);
|
||||
const [liveState, setLiveState] = useState<LiveState | null>(null);
|
||||
const checking = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
if (checking.current) return;
|
||||
checking.current = true;
|
||||
try {
|
||||
const [found] = await client.getSomeEmails([emailId], ownerAccountId);
|
||||
if (cancelled) return;
|
||||
setPresence(found ? "present" : "gone");
|
||||
if (found?.mailboxIds) setMailboxIds(Object.keys(found.mailboxIds).sort());
|
||||
if (found) {
|
||||
setLiveState({ keywords: found.keywords, mailboxIds: found.mailboxIds });
|
||||
// What the next star or read toggle starts from.
|
||||
useEmailStore.setState((state) => ({
|
||||
emails: state.emails.map((email) => (email.id === emailId ? { ...email, keywords: found.keywords, mailboxIds: found.mailboxIds } : email)),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// No answer says nothing about the message: it stays on screen.
|
||||
} finally {
|
||||
checking.current = false;
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type === "state-change") void check();
|
||||
});
|
||||
const onFocus = () => void check();
|
||||
window.addEventListener("focus", onFocus);
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void check();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
channel?.close();
|
||||
window.removeEventListener("focus", onFocus);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [client, emailId, ownerAccountId]);
|
||||
|
||||
return { presence, mailboxIds, liveState };
|
||||
}
|
||||
|
||||
function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) {
|
||||
const t = useTranslations("pwaWindows");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined);
|
||||
const client = useAuthStore((s) => (login ? s.getClientForAccount(login) : undefined) ?? s.client);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const [subject, setSubject] = useState<string | null>(null);
|
||||
const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled"));
|
||||
useDocumentTitle(subjectInTitle ? subject : null);
|
||||
|
||||
// Folders first: without them Delete has no Trash to move to, Archive has no
|
||||
// button yet, and the toolbar moves under the pointer once they arrive.
|
||||
const [foldersReady, setFoldersReady] = useState(mailboxes.length > 0);
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
let cancelled = false;
|
||||
void Promise.resolve(useEmailStore.getState().fetchMailboxes(client)).catch(() => {}).finally(() => {
|
||||
if (!cancelled) setFoldersReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Once per client: an account without folders shouldn't ask forever.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client]);
|
||||
|
||||
const { presence, mailboxIds, liveState } = useMessagePresence(emailId, ownerAccountId);
|
||||
const firstMailboxIds = useRef<string | null>(null);
|
||||
if (mailboxIds && firstMailboxIds.current === null) firstMailboxIds.current = mailboxIds.join();
|
||||
const moved = !!mailboxIds && firstMailboxIds.current !== mailboxIds.join();
|
||||
const movedTo = moved ? mailboxes.find((mailbox) => mailboxIds.includes(mailbox.originalId || mailbox.id)) : undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
announceMailChanged(login ?? null);
|
||||
closePopoutWindow();
|
||||
}, [login]);
|
||||
|
||||
const handleMailChanged = useCallback(() => announceMailChanged(login ?? null), [login]);
|
||||
|
||||
const handleLoaded = useCallback((email: Email) => {
|
||||
setSubject(email.subject || null);
|
||||
// Delete, move, star and read look the message up in the list and do
|
||||
// nothing when it isn't there, and this window has no list of its own.
|
||||
useEmailStore.setState({ emails: [email], selectedMailbox: Object.keys(email.mailboxIds ?? {})[0] ?? "" });
|
||||
}, []);
|
||||
|
||||
// Reply and forward open a composer window of their own.
|
||||
const openCompose = useCallback(async (data: Omit<ProComposeTabData, "sessionId">) => {
|
||||
const opened = openComposerWindow(login);
|
||||
if (!opened) {
|
||||
toast.error(t("popup_blocked"));
|
||||
return false;
|
||||
}
|
||||
const shown = await opened.handOver({
|
||||
mode: data.mode,
|
||||
replyTo: data.replyTo,
|
||||
initialDraftText: data.initialDraftText,
|
||||
initialData: data.initialData,
|
||||
prefill: data.prefill,
|
||||
sourceEmailId: data.sourceEmailId,
|
||||
sourceEmailSource: data.sourceEmailSource,
|
||||
});
|
||||
if (!shown) {
|
||||
opened.cancel();
|
||||
toast.error(t("window_not_ready"));
|
||||
}
|
||||
return shown;
|
||||
}, [login, t]);
|
||||
|
||||
if (!foldersReady) return <PopoutSkeleton label={t("opening")} />;
|
||||
|
||||
if (presence === "gone") {
|
||||
return (
|
||||
<PopoutNotice title={t("message_gone_title")} body={t("message_gone_body")}>
|
||||
<Button onClick={handleClose}>{t("close_window")}</Button>
|
||||
</PopoutNotice>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{moved && (
|
||||
<div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
|
||||
{movedTo
|
||||
? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) })
|
||||
: t("message_moved")}
|
||||
</div>
|
||||
)}
|
||||
<ProEmailView
|
||||
emailId={emailId}
|
||||
client={client ?? undefined}
|
||||
clientAccountId={login}
|
||||
accountId={ownerAccountId}
|
||||
onLoaded={handleLoaded}
|
||||
onClose={handleClose}
|
||||
onOpenCompose={openCompose}
|
||||
liveState={liveState}
|
||||
onMailChanged={handleMailChanged}
|
||||
className="min-h-0 w-full flex-1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/popout/message/<id>`: one message in its own window, with no rail, sidebar or list. */
|
||||
export function PopoutMessageApp({ id }: { id: string }) {
|
||||
const [launch] = useState(() => readLaunch(id));
|
||||
return (
|
||||
<PopoutShell kind="message" accountId={launch.accountId}>
|
||||
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} />
|
||||
</PopoutShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useAuthStore, SHARED_SESSION_ENDED_EVENT } from "@/stores/auth-store";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { flushComposers } from "@/lib/compose-flush-registry";
|
||||
import { openPopoutChannel, requestWindowSession } from "@/lib/popout/channel";
|
||||
import { closePopoutWindow } from "@/lib/popout/window-role";
|
||||
import { onSignedOutElsewhere } from "@/lib/sign-out-cleanup";
|
||||
|
||||
/** How long a closing pop-out waits for its composer to save, when the account went away elsewhere. */
|
||||
const CLOSE_FLUSH_MS = 3000;
|
||||
/** How long it waits to hear that another window still holds the login. */
|
||||
const STILL_HELD_MS = 800;
|
||||
|
||||
/** The session gate and page frame of a pop-out window. Takes the session from the main window (lib/popout). */
|
||||
export function PopoutShell({ kind, accountId, children }: {
|
||||
kind: "message" | "compose";
|
||||
/** The login to open; left out, the main window's active one. */
|
||||
accountId?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations("pwaWindows");
|
||||
// Fetches the organisation's policy, which device policies and send rules read.
|
||||
useConfig();
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "no-session">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void useAuthStore.getState().startSharedSession({ accountId }).then((result) => {
|
||||
if (!cancelled) setStatus(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accountId]);
|
||||
|
||||
// The session ended elsewhere: save what can be saved, and go. A composer
|
||||
// whose text didn't make it stays, as the only place that text still is.
|
||||
const endSession = useCallback(async () => {
|
||||
const flushed = await flushComposers(CLOSE_FLUSH_MS);
|
||||
if (kind === "compose" && (flushed.failed > 0 || flushed.timedOut)) setStatus("no-session");
|
||||
else closePopoutWindow();
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
const channel = openPopoutChannel((message) => {
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
if (message.type !== "account-removed" || message.accountId !== active) return;
|
||||
// One tab dropping a login it couldn't restore says nothing about the
|
||||
// tab that is signed in with it.
|
||||
void requestWindowSession({ accountId: active }, STILL_HELD_MS).then((held) => {
|
||||
if (!held?.sessions.some((session) => session.accountId === active)) void endSession();
|
||||
});
|
||||
});
|
||||
const stopSignOut = onSignedOutElsewhere(() => void endSession());
|
||||
return () => {
|
||||
channel?.close();
|
||||
stopSignOut();
|
||||
};
|
||||
}, [endSession]);
|
||||
|
||||
// The session can't be renewed and no main window is left to do it. A
|
||||
// message stops showing; a composer stays, its text still worth copying.
|
||||
useEffect(() => {
|
||||
const onEnded = () => setStatus("no-session");
|
||||
window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded);
|
||||
return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded);
|
||||
}, []);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button>
|
||||
<Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-popout-window={kind} data-popout-status={status} className="flex h-dvh flex-col overflow-hidden bg-background pt-[var(--app-top-inset)]">
|
||||
{status === "loading" && <PopoutSkeleton label={t("opening")} />}
|
||||
{status === "no-session" && kind === "message" && (
|
||||
<PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}>{actions}</PopoutNotice>
|
||||
)}
|
||||
{status === "no-session" && kind === "compose" && (
|
||||
<div role="alert" data-popout-session-ended="" className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
|
||||
<span className="min-w-0 flex-1">{t("session_ended_title")}. {t("session_ended_body")}</span>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
{/* One place in the tree, so a composer stays mounted, text and all, when the session ends. */}
|
||||
{(status === "ready" || (status === "no-session" && kind === "compose")) && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutSkeleton({ label }: { label: string }) {
|
||||
return (
|
||||
<div role="status" aria-label={label} className="flex flex-1 flex-col gap-4 p-6" data-popout-skeleton="">
|
||||
<div className="h-6 w-2/3 rounded bg-muted" />
|
||||
<div className="h-4 w-1/3 rounded bg-muted" />
|
||||
<div className="mt-4 h-4 w-full rounded bg-muted" />
|
||||
<div className="h-4 w-5/6 rounded bg-muted" />
|
||||
<div className="h-4 w-4/6 rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutNotice({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center" data-popout-notice="">
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{body}</p>
|
||||
{children && <div className="mt-2 flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { startWindowSync } from "@/lib/desktop/window-sync";
|
||||
|
||||
/** See lib/desktop/window-sync.ts. */
|
||||
export function WindowSyncHost() {
|
||||
useEffect(() => startWindowSync(), []);
|
||||
return null;
|
||||
}
|
||||
@@ -136,6 +136,36 @@ describe('Pro email view - editing a draft', () => {
|
||||
expect(initialData).toMatchObject({ draftId: 'eaaaaab', draftAccountId: 'c' });
|
||||
});
|
||||
|
||||
describe('in a window of its own', () => {
|
||||
async function editIn({ onOpenCompose, onClose }: { onOpenCompose: () => Promise<boolean>; onClose: () => void }) {
|
||||
const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' };
|
||||
render(<ProEmailView emailId="d1" client={client as never} onOpenCompose={onOpenCompose} onClose={onClose} />);
|
||||
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
|
||||
(viewerProps.current!.onEditDraft as () => void)();
|
||||
}
|
||||
|
||||
it('leaves the message window only once the draft is on screen in the other, or it would take the draft with it', async () => {
|
||||
let show: (shown: boolean) => void = () => {};
|
||||
const onClose = vi.fn();
|
||||
const onOpenCompose = vi.fn(() => new Promise<boolean>((resolve) => { show = resolve; }));
|
||||
await editIn({ onOpenCompose, onClose });
|
||||
await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
show(true);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('stays when the draft did not make it', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenCompose = vi.fn(async () => false);
|
||||
await editIn({ onOpenCompose, onClose });
|
||||
await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(openComposeTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens an own draft without an account, as before', async () => {
|
||||
const initialData = await openAndEdit(undefined);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { threadKeyFor } from "@/lib/thread-utils";
|
||||
import { formatRejectedRecipients } from "@/lib/jmap/client";
|
||||
import { announceSent } from "@/lib/plugin-after-send";
|
||||
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
|
||||
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
|
||||
import { openComposerWindow } from "@/lib/popout/open";
|
||||
|
||||
interface ProComposeTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -212,6 +214,8 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const popoutsOn = usePopoutsAvailable();
|
||||
|
||||
// Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`).
|
||||
// Without it `mode === 'compose'` resolves `findComposeIdentityId(identities,
|
||||
// undefined)` -> null and every new message in the Pro shell defaults to the
|
||||
@@ -234,6 +238,22 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
);
|
||||
});
|
||||
|
||||
const popOut = useCallback(() => {
|
||||
const target = openComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
if (!target) return null;
|
||||
return {
|
||||
cancel: target.cancel,
|
||||
handOver: (state: ComposerDraftData) => target.handOver({
|
||||
mode: state.mode,
|
||||
replyTo: state.replyTo,
|
||||
initialData: state,
|
||||
sourceEmailId: data.sourceEmailId,
|
||||
sourceEmailSource: data.sourceEmailSource,
|
||||
composeFromAccountEmail,
|
||||
}),
|
||||
};
|
||||
}, [composeFromAccountEmail, data.sourceEmailId, data.sourceEmailSource]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
@@ -254,6 +274,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
requestCloseRef={requestCloseRef}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
onSaveState={handleSaveState}
|
||||
popOut={popoutsOn ? popOut : undefined}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-repl
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
|
||||
import { canDownload } from '@/lib/device-policy';
|
||||
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
|
||||
import { isFilePreviewable } from '@/lib/file-preview';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
@@ -66,6 +67,16 @@ interface ProEmailViewProps {
|
||||
accountId?: string;
|
||||
/** Fires once the full message is loaded (tab retitling, sidebar highlight). */
|
||||
onLoaded?: (email: Email) => void;
|
||||
/**
|
||||
* Opens a reply, forward or draft somewhere else than a Pro tab: a pop-out
|
||||
* window (components/popout) has no tabs. Resolves true once it is on screen
|
||||
* there: a draft leaves this view only then.
|
||||
*/
|
||||
onOpenCompose?: (data: Omit<ProComposeTabData, 'sessionId'>) => Promise<boolean>;
|
||||
/** Flags and folders as the server has them now, when the host tracks them (a pop-out). */
|
||||
liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null;
|
||||
/** This view changed the message's flags, for a host other windows should hear from. */
|
||||
onMailChanged?: () => void;
|
||||
/**
|
||||
* The message left this view: deleted, archived, moved, or reopened in the
|
||||
* composer. The host decides what that means (close the tab / clear the
|
||||
@@ -121,7 +132,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
||||
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
|
||||
* reading pane.
|
||||
*/
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onClose, className }: ProEmailViewProps) {
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tRemove = useTranslations('email_list.confirm_remove');
|
||||
@@ -176,6 +187,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
onOpened?: () => void,
|
||||
) => {
|
||||
const open = (prefill: ComposePrefill | null) => {
|
||||
if (onOpenCompose) {
|
||||
void onOpenCompose({ ...data, prefill }).then((shown) => {
|
||||
if (shown) onOpened?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill });
|
||||
onOpened?.();
|
||||
@@ -192,7 +209,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
})).then((prefill) => {
|
||||
if (prefill) open(Object.keys(prefill).length > 0 ? prefill : null);
|
||||
});
|
||||
}, [identities, openComposeTabNow, t]);
|
||||
}, [identities, onOpenCompose, openComposeTabNow, t]);
|
||||
|
||||
// Held in a ref so a host passing an inline callback doesn't refetch the
|
||||
// message on every render.
|
||||
@@ -243,6 +260,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
};
|
||||
}, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveState) return;
|
||||
setEmail((prev) => (prev && prev.id === emailId
|
||||
? { ...prev, keywords: liveState.keywords ?? prev.keywords, mailboxIds: liveState.mailboxIds ?? prev.mailboxIds }
|
||||
: prev));
|
||||
}, [liveState, emailId]);
|
||||
|
||||
const currentMailboxRole = useMemo(() => {
|
||||
if (!email) return undefined;
|
||||
const mb = email.mailboxIds
|
||||
@@ -368,6 +392,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
if (!(await toggleStar(client, email.id, emailKeyFor(email)))) return;
|
||||
onMailChanged?.();
|
||||
// Reflect locally - the viewer re-reads from email-store's selectedEmail
|
||||
// shape only for the mail tab; here we update our local copy too.
|
||||
setEmail((prev) => prev ? {
|
||||
@@ -381,12 +406,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
console.error('Toggle star failed:', err);
|
||||
toast.error(tNotifications('error_updating'));
|
||||
}
|
||||
}, [client, email, toggleStar, tNotifications]);
|
||||
}, [client, email, toggleStar, tNotifications, onMailChanged]);
|
||||
|
||||
const handleMarkAsRead = useCallback(async (emailIdToMark: string, read: boolean, opts?: { automatic?: boolean }) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
if (!(await markAsRead(client, emailIdToMark, read))) return;
|
||||
onMailChanged?.();
|
||||
setEmail((prev) => prev && prev.id === emailIdToMark ? {
|
||||
...prev,
|
||||
keywords: { ...prev.keywords, $seen: read },
|
||||
@@ -396,7 +422,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
// A refused mark-as-read on opening needs no notice (see EmailViewer).
|
||||
if (!opts?.automatic) toast.error(tNotifications('error_updating'));
|
||||
}
|
||||
}, [client, markAsRead, tNotifications]);
|
||||
}, [client, markAsRead, tNotifications, onMailChanged]);
|
||||
|
||||
const handleSetTag = useCallback((emailIdToTag: string, tagId: string | null) => {
|
||||
if (!email || email.id !== emailIdToTag) return;
|
||||
@@ -457,6 +483,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
return { blob, contentType: preview.type || blob.type };
|
||||
}, [preview]);
|
||||
|
||||
const openInWindow = useOpenEmailInWindow();
|
||||
|
||||
const handleQuickReply = useCallback(async (body: string) => {
|
||||
handleReply(body);
|
||||
}, [handleReply]);
|
||||
@@ -534,6 +562,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetTag={handleSetTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onOpenInWindow={openInWindow && email ? () => openInWindow(email) : undefined}
|
||||
onQuickReply={handleQuickReply}
|
||||
onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
|
||||
onEditDraft={handleEditDraft}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ProInterfaceRedirect() {
|
||||
|
||||
if (!takingOver) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" data-pro-takeover="">
|
||||
<div className="fixed inset-0 z-50 pt-[var(--titlebar-inset)]" data-pro-takeover="">
|
||||
<ProSkeleton label={label} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
|
||||
import { useEffect } from "react";
|
||||
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import type { RailLabels } from "@/components/boot-skeleton/shell";
|
||||
@@ -7,7 +8,6 @@ import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/mailto";
|
||||
@@ -34,9 +34,7 @@ function openFallbackAppTab(raw: string): boolean {
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
return !isStandaloneDisplayMode() && window.history.length > 1;
|
||||
}
|
||||
|
||||
async function focusExistingClient() {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { flushComposers } from "@/lib/compose-flush-registry";
|
||||
import { isPopoutPath } from "@/lib/popout/window-role";
|
||||
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { matchSurface, type AppSurface } from "@/lib/deep-links";
|
||||
import {
|
||||
@@ -45,7 +47,6 @@ type LaunchParams = {
|
||||
targetURL?: string;
|
||||
files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>;
|
||||
};
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -91,11 +92,6 @@ const OFFER_FILE_COUNT = 5;
|
||||
|
||||
type ShareOffer = { subject: string; body: string; files: string[] };
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
}
|
||||
|
||||
function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean {
|
||||
const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`;
|
||||
const opened = window.open(url, "_blank");
|
||||
@@ -206,7 +202,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
}, [pathname, leaveFor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/")) return;
|
||||
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
|
||||
|
||||
return listenForMailtoRequests((pending) => {
|
||||
savePendingMailto(pending);
|
||||
@@ -221,7 +217,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
}, [pathname, router, t, appName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.launchQueue) return;
|
||||
if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return;
|
||||
|
||||
window.launchQueue.setConsumer((launchParams) => {
|
||||
if (launchParams.files && launchParams.files.length > 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
|
||||
import { useEffect } from "react";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { savePendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
@@ -7,7 +8,6 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { CalendarSkeleton, type CalendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
|
||||
import type { RailLabels } from "@/components/boot-skeleton/shell";
|
||||
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
function getProtocolPathPrefix(): string {
|
||||
const marker = "/protocol/webcal";
|
||||
@@ -34,9 +34,7 @@ function openFallbackAppTab(raw: string): boolean {
|
||||
}
|
||||
|
||||
function shouldOpenFallbackAppTab(): boolean {
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
return !standalone && window.history.length > 1;
|
||||
return !isStandaloneDisplayMode() && window.history.length > 1;
|
||||
}
|
||||
|
||||
interface WebcalProtocolClientProps {
|
||||
|
||||
@@ -910,7 +910,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
if (!isDesktop) {
|
||||
if (mobileShowContent) {
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<IconButton label={t('back_to_settings')} size="icon-lg" onClick={() => window.history.back()}>
|
||||
@@ -939,7 +939,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<AppTopBannerSlot />
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<IconButton label={t('back_to_mail')} size="icon-lg" onClick={() => router.push('/')}>
|
||||
@@ -1057,7 +1057,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
</AppMain>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div className={cn("flex flex-col pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{mvShell && (
|
||||
|
||||
@@ -155,8 +155,8 @@ export function Dialog({
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0"
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-inset))] animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-inset)]"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
@@ -72,7 +73,9 @@ function remind(view: CalendarAlertView, show: () => void): void {
|
||||
}
|
||||
|
||||
export function useCalendarAlerts() {
|
||||
const { isAuthenticated, client } = useAuthStore();
|
||||
const { isAuthenticated: signedIn, client } = useAuthStore();
|
||||
// The main window rings; a pop-out would ring the same alert again.
|
||||
const isAuthenticated = signedIn && !isPopoutWindow();
|
||||
const { events, calendars, supportsCalendar } = useCalendarStore();
|
||||
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore();
|
||||
const { tasks: storeTasks } = useTaskStore();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { resolveEmailBlobContext } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { openMessageWindow } from "@/lib/popout/open";
|
||||
import { popoutsAvailable } from "@/lib/popout/support";
|
||||
import { isPopoutWindow } from "@/lib/popout/window-role";
|
||||
|
||||
/**
|
||||
* Whether to offer "Open in new window" here. False on the server and in the
|
||||
* first render, so the markup hydrates the same; false in the demo, whose
|
||||
* client has no session to share, and inside a pop-out (for a message: it is
|
||||
* already in its own window).
|
||||
*/
|
||||
export function usePopoutsAvailable(): boolean {
|
||||
const isDemoMode = useAuthStore((s) => s.isDemoMode);
|
||||
const [available, setAvailable] = useState(false);
|
||||
useEffect(() => {
|
||||
const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow());
|
||||
check();
|
||||
// Installing the running tab turns it into a window without a reload.
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const media = window.matchMedia("(display-mode: standalone)");
|
||||
media.addEventListener?.("change", check);
|
||||
return () => media.removeEventListener?.("change", check);
|
||||
}, []);
|
||||
return available && !isDemoMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a message in its own window, through the login and the account it was
|
||||
* read in. Null when that isn't on offer here. Says so when the browser
|
||||
* blocks the window; nothing else changes then.
|
||||
*/
|
||||
export function useOpenEmailInWindow(): ((email: Email) => void) | null {
|
||||
const available = usePopoutsAvailable();
|
||||
const t = useTranslations("pwaWindows");
|
||||
const open = useCallback((email: Email) => {
|
||||
const auth = useAuthStore.getState();
|
||||
const source = resolveEmailBlobContext(email, auth.client);
|
||||
const opened = openMessageWindow({
|
||||
emailId: email.id,
|
||||
localAccountId: source.clientAccountId ?? auth.activeAccountId,
|
||||
ownerAccountId: source.accountId,
|
||||
});
|
||||
if (!opened) toast.error(t("popup_blocked"));
|
||||
}, [t]);
|
||||
return available ? open : null;
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Windows of an installed app (design B, step 2) through the real UI on a
|
||||
* VM 106 test Stalwart with the ent-* accounts:
|
||||
* - the title bar next to the native window controls;
|
||||
* - "Save all to a folder", with a stubbed showDirectoryPicker;
|
||||
* - pop-out message and composer windows: session, draft handover, send,
|
||||
* close with unsaved text, a message deleted or moved, state across windows.
|
||||
*
|
||||
* Headless Chromium has no installed-app mode, so the display mode and the
|
||||
* overlay API are stubbed by an init script (which reaches popups too).
|
||||
* window.open, the second window and everything behind it are real. What this
|
||||
* can't show: a real install opening a popup inside the app rather than in
|
||||
* the browser. A person has to check that once in Chrome and Edge.
|
||||
*
|
||||
* Rig:
|
||||
* BUILD=1 bash start-prod.sh <worktree> <port> <jmap-url> pw-1.0
|
||||
* bash run-spec.sh <worktree> <port> <jmap-url> 1.0 integration/tests/23-do-pwa-windows.spec.ts
|
||||
*/
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
/** A server check every second: tighter polling gets a test account rate limited. */
|
||||
const POLL = [1000];
|
||||
const STANDALONE = `(() => {
|
||||
const real = window.matchMedia.bind(window);
|
||||
window.matchMedia = (query) => query.includes('display-mode: standalone')
|
||||
? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } }
|
||||
: real(query);
|
||||
})();`;
|
||||
const OVERLAY = `(() => {
|
||||
const overlay = new EventTarget();
|
||||
overlay.visible = true;
|
||||
overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 });
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay });
|
||||
})();`;
|
||||
const BRIDGE = `window.bulwarkDesktop = { windows: {} };`;
|
||||
|
||||
async function appPage(browser: Browser, ...initScripts: string[]): Promise<Page> {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
for (const script of initScripts) await context.addInitScript(script);
|
||||
const page = await context.newPage();
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.fill('#username', ENT.alice);
|
||||
await page.fill('#password', ENT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A toast: the text is also in the live region for screen readers. */
|
||||
function toast(page: Page, text: string | RegExp) {
|
||||
return page.getByRole('region', { name: 'Notifications' }).getByText(text).first();
|
||||
}
|
||||
|
||||
function row(page: Page, subject: string) {
|
||||
return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first();
|
||||
}
|
||||
|
||||
async function openInMain(page: Page, subject: string): Promise<void> {
|
||||
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
|
||||
await row(page, subject).click();
|
||||
}
|
||||
|
||||
async function deliverToAlice(subject: string): Promise<string> {
|
||||
await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` });
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 });
|
||||
return found.id;
|
||||
}
|
||||
|
||||
/** A message in Alice's inbox with two attachments of the same name and one other. */
|
||||
async function messageWithAttachments(subject: string): Promise<void> {
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const inbox = await alice.mailboxByRole('inbox');
|
||||
const res = await alice.request([['Email/set', {
|
||||
accountId: alice.accountId,
|
||||
create: { m: {
|
||||
mailboxIds: { [inbox!.id]: true },
|
||||
from: [{ email: ENT.bob }],
|
||||
to: [{ email: ENT.alice }],
|
||||
subject,
|
||||
bodyValues: {
|
||||
t: { value: 'See the files.' },
|
||||
a: { value: 'first copy' },
|
||||
b: { value: 'second copy' },
|
||||
c: { value: 'notes' },
|
||||
},
|
||||
bodyStructure: {
|
||||
type: 'multipart/mixed',
|
||||
subParts: [
|
||||
{ partId: 't', type: 'text/plain' },
|
||||
{ partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
|
||||
{ partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' },
|
||||
{ partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' },
|
||||
],
|
||||
},
|
||||
} },
|
||||
}, '0']]);
|
||||
if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1]));
|
||||
}
|
||||
|
||||
/** Stubs the picker on the page, with a folder that records what it is given. */
|
||||
async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise<void> {
|
||||
await page.evaluate(({ failOn, cancel }) => {
|
||||
const files = new Map<string, string>();
|
||||
(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles = files;
|
||||
(window as unknown as { __pickerCalls: number }).__pickerCalls = 0;
|
||||
(window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => {
|
||||
(window as unknown as { __pickerCalls: number }).__pickerCalls += 1;
|
||||
if (cancel) throw new DOMException('closed', 'AbortError');
|
||||
return {
|
||||
name: 'Saved mail',
|
||||
async getFileHandle(name: string, options?: { create?: boolean }) {
|
||||
if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError');
|
||||
return {
|
||||
createWritable: async () => ({
|
||||
write: async (data: Blob) => {
|
||||
if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError');
|
||||
files.set(name, await data.text());
|
||||
},
|
||||
close: async () => {},
|
||||
abort: async () => {},
|
||||
}),
|
||||
};
|
||||
},
|
||||
async removeEntry(name: string) { files.delete(name); },
|
||||
};
|
||||
};
|
||||
}, opts);
|
||||
}
|
||||
|
||||
const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles.entries()]);
|
||||
|
||||
function watchAuthCalls(context: BrowserContext): string[] {
|
||||
const calls: string[] = [];
|
||||
context.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== 'PUT') return;
|
||||
if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') {
|
||||
calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function openPopout(page: Page, trigger: () => Promise<void>): Promise<Page> {
|
||||
const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]);
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
return popup;
|
||||
}
|
||||
|
||||
async function popOutMessage(page: Page, subject: string): Promise<Page> {
|
||||
await openInMain(page, subject);
|
||||
const button = page.locator('[data-testid="viewer-open-in-window"]').first();
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
const popup = await openPopout(page, () => button.click());
|
||||
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
|
||||
return popup;
|
||||
}
|
||||
|
||||
async function draftsCalled(subject: string): Promise<Array<{ id: string; subject: string }>> {
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const drafts = await alice.mailboxByRole('drafts');
|
||||
const res = await alice.request([
|
||||
['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'],
|
||||
['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
|
||||
]);
|
||||
return res.methodResponses[1][1].list;
|
||||
}
|
||||
|
||||
test.beforeAll(() => requireEntEnv());
|
||||
|
||||
test.describe('the manifest and the title bar', () => {
|
||||
test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
|
||||
const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
|
||||
const manifest = await res.json();
|
||||
expect(manifest.display).toBe('standalone');
|
||||
expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']);
|
||||
});
|
||||
|
||||
test('without an overlay nothing changes', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
|
||||
const padding = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
|
||||
});
|
||||
expect(padding).toEqual({ padding: '0px', top: 0 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
|
||||
const page = await appPage(browser, OVERLAY);
|
||||
const strip = page.locator('[data-window-title-bar]');
|
||||
await expect(strip).toBeVisible();
|
||||
const box = await strip.boundingBox();
|
||||
expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
|
||||
expect(box!.width).toBe(1440 - 140);
|
||||
const region = await strip.evaluate((el) => {
|
||||
const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
|
||||
return style.appRegion ?? style.webkitAppRegion;
|
||||
});
|
||||
expect(region).toBe('drag');
|
||||
// The window title, in small text.
|
||||
await expect(strip).toContainText(/\S/);
|
||||
const rail = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop };
|
||||
});
|
||||
expect(rail.padding).toBe('40px');
|
||||
await shot(page, 'pwa-windows-title-bar');
|
||||
// A full-page dialog keeps clear of it too.
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('save all attachments to a folder', () => {
|
||||
const SUBJECT = `Files ${RUN}`;
|
||||
|
||||
test.beforeAll(() => messageWithAttachments(SUBJECT));
|
||||
|
||||
test('writes every file, numbers the same name, and says how many', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await stubDirectoryPicker(page);
|
||||
await openInMain(page, SUBJECT);
|
||||
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
|
||||
await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
|
||||
const files = new Map(await folderFiles(page));
|
||||
expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']);
|
||||
expect(files.get('notes.txt')).toContain('notes');
|
||||
expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a write failing midway says how many were saved', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await stubDirectoryPicker(page, { failOn: 'notes.txt' });
|
||||
await openInMain(page, SUBJECT);
|
||||
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
|
||||
await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('closing the picker does nothing', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await stubDirectoryPicker(page, { cancel: true });
|
||||
await openInMain(page, SUBJECT);
|
||||
await page.locator('[data-testid="attachments-save-to-folder"]').first().click();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1);
|
||||
await expect(toast(page, /Saved \d/)).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('is gone when the organisation blocks downloads', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const page = await appPage(browser);
|
||||
await stubDirectoryPicker(page);
|
||||
await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => {
|
||||
await page.reload();
|
||||
await stubDirectoryPicker(page);
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await openInMain(page, SUBJECT);
|
||||
await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0);
|
||||
});
|
||||
await admin.close();
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('pop-out windows', () => {
|
||||
test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
for (const path of ['/en/popout/compose', '/en/popout/message/x1']) {
|
||||
const res = await context.request.get(path);
|
||||
expect(res.status(), path).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html, path).toContain('data-popout-skeleton');
|
||||
expect(html, path).toContain('data-popout-status="loading"');
|
||||
expect(html, path).not.toContain('animate-spin');
|
||||
expect(html, path).not.toContain('data-sk-rail');
|
||||
expect(html, path).not.toContain('data-boot-skeleton=');
|
||||
expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window'));
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a browser tab offers none of it', async ({ browser }) => {
|
||||
const subject = `Tab ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser);
|
||||
await openInMain(page, subject);
|
||||
await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0);
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => {
|
||||
const subject = `Bridge ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, BRIDGE);
|
||||
await openInMain(page, subject);
|
||||
await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => {
|
||||
const subject = `Popout ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const calls = watchAuthCalls(page.context());
|
||||
const popup = await popOutMessage(page, subject);
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
await expect(popup.locator('nav')).toHaveCount(0);
|
||||
expect(popup.url()).toContain('/popout/message/');
|
||||
await expect(popup).toHaveTitle(new RegExp(subject));
|
||||
expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]);
|
||||
await shot(popup, 'pwa-windows-popout-message');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('opening the same message again focuses its window', async ({ browser }) => {
|
||||
const subject = `Twice ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutMessage(page, subject);
|
||||
const opened: Page[] = [];
|
||||
page.context().on('page', (p) => opened.push(p));
|
||||
const url = popup.url();
|
||||
let reloaded = false;
|
||||
popup.on('framenavigated', () => { reloaded = true; });
|
||||
await page.locator('[data-testid="viewer-open-in-window"]').first().click();
|
||||
await popup.waitForTimeout(1000);
|
||||
expect(opened).toEqual([]);
|
||||
expect(reloaded).toBe(false);
|
||||
expect(popup.url()).toBe(url);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => {
|
||||
const subject = `Forgotten ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
await page.evaluate(() => {
|
||||
const registry = JSON.parse(localStorage.getItem('account-registry')!);
|
||||
for (const account of registry.state.accounts) account.rememberMe = false;
|
||||
localStorage.setItem('account-registry', JSON.stringify(registry));
|
||||
});
|
||||
await page.context().clearCookies({ name: 'jmap_session' });
|
||||
const popup = await popOutMessage(page, subject);
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length);
|
||||
expect(accounts).toBeGreaterThan(0);
|
||||
await expect(accountSwitcher(page)).toBeVisible();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => {
|
||||
const subject = `Alone ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutMessage(page, subject);
|
||||
const before = await popup.evaluate(() => localStorage.getItem('account-registry'));
|
||||
await page.close();
|
||||
await popup.reload();
|
||||
await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before);
|
||||
await popup.context().close();
|
||||
});
|
||||
|
||||
test('a message deleted or moved while open says so', async ({ browser }) => {
|
||||
const subject = `Gone ${RUN}`;
|
||||
const emailId = await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutMessage(page, subject);
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const archive = await alice.mailboxByRole('archive');
|
||||
if (archive) {
|
||||
await alice.moveEmail(emailId, archive.id);
|
||||
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
|
||||
await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 });
|
||||
// A move keeps the id: the message stays readable.
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible();
|
||||
}
|
||||
|
||||
await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]);
|
||||
await popup.evaluate(() => window.dispatchEvent(new Event('focus')));
|
||||
await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('deleting from the pop-out closes it', async ({ browser }) => {
|
||||
const subject = `Delete ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutMessage(page, subject);
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
const closed = popup.waitForEvent('close');
|
||||
await popup.getByRole('button', { name: /^Delete$/ }).first().click();
|
||||
await closed;
|
||||
// The main window's list follows.
|
||||
await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 });
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const trash = await alice.mailboxByRole('trash');
|
||||
await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {}), { intervals: POLL }).toEqual([trash!.id]);
|
||||
});
|
||||
|
||||
test('state follows across windows without a reload', async ({ browser }) => {
|
||||
const subject = `Sync ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutMessage(page, subject);
|
||||
await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 });
|
||||
let navigated = 0;
|
||||
popup.on('framenavigated', () => { navigated += 1; });
|
||||
|
||||
// Theme.
|
||||
await page.evaluate(() => {
|
||||
const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}');
|
||||
stored.state.theme = 'dark';
|
||||
localStorage.setItem('theme-storage', JSON.stringify(stored));
|
||||
});
|
||||
await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 });
|
||||
|
||||
// Language.
|
||||
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 })));
|
||||
await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 });
|
||||
await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 })));
|
||||
await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 });
|
||||
|
||||
// Read state: from the pop-out to the main window's list. The main window
|
||||
// looks at another message: the one it has open it would mark read again.
|
||||
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
|
||||
const other = `Sync other ${RUN}`;
|
||||
await deliverToAlice(other);
|
||||
await openInMain(page, other);
|
||||
await popup.getByTitle('Mark as unread').first().click();
|
||||
await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 });
|
||||
|
||||
// And from the main window to the pop-out.
|
||||
await row(page, subject).click();
|
||||
await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 });
|
||||
await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 });
|
||||
expect(navigated).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a composer in its own window', () => {
|
||||
async function popOutComposer(page: Page, subject: string): Promise<Page> {
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const input = page.locator('[data-testid="composer-to"] input').first();
|
||||
await input.click();
|
||||
await input.fill(ENT.bob);
|
||||
await input.press('Enter');
|
||||
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
|
||||
await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`);
|
||||
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
|
||||
await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
|
||||
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 });
|
||||
return popup;
|
||||
}
|
||||
|
||||
test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => {
|
||||
const subject = `Moved ${RUN}`;
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutComposer(page, subject);
|
||||
|
||||
// Gone from the main window, with no "continue draft" left behind.
|
||||
await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0);
|
||||
await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob);
|
||||
await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`);
|
||||
await expect(popup.locator('nav')).toHaveCount(0);
|
||||
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
|
||||
const [first] = await draftsCalled(subject);
|
||||
await shot(popup, 'pwa-windows-popout-composer');
|
||||
|
||||
// Further edits replace that draft, they don't start another.
|
||||
await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
|
||||
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
|
||||
await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000, intervals: POLL }).toBe(false);
|
||||
|
||||
const closed = popup.waitForEvent('close');
|
||||
await popup.locator('[data-testid="composer-send"]').first().click();
|
||||
await closed;
|
||||
|
||||
const bob = await jmapAs(ENT.bob);
|
||||
await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 });
|
||||
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(0);
|
||||
// The main window says it went, since the pop-out can't any more.
|
||||
await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('closing the window with unsaved text saves the draft', async ({ browser }) => {
|
||||
const subject = `Unsaved ${RUN}`;
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutComposer(page, subject);
|
||||
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
|
||||
const typed = `${subject} typed`;
|
||||
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
|
||||
// Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it.
|
||||
let prompted = false;
|
||||
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
|
||||
const closed = popup.waitForEvent('close');
|
||||
await popup.close({ runBeforeUnload: true });
|
||||
await closed;
|
||||
expect(prompted).toBe(false);
|
||||
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
|
||||
// It replaced the version the window started from.
|
||||
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000, intervals: POLL }).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => {
|
||||
const subject = `Asked ${RUN}`;
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const popup = await popOutComposer(page, subject);
|
||||
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
|
||||
await page.close();
|
||||
const typed = `${subject} typed`;
|
||||
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
|
||||
const asked = new Promise<void>((resolve) => {
|
||||
popup.once('dialog', (dialog) => {
|
||||
expect(dialog.type()).toBe('beforeunload');
|
||||
void dialog.dismiss();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await popup.close({ runBeforeUnload: true });
|
||||
await asked;
|
||||
expect(popup.isClosed()).toBe(false);
|
||||
await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
|
||||
});
|
||||
|
||||
test('a blocked window leaves the composer where it is', async ({ browser }) => {
|
||||
const subject = `Blocked ${RUN}`;
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
await page.evaluate(() => { window.open = () => null; });
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
|
||||
await page.locator('[data-testid="composer-open-in-window"]').first().click();
|
||||
await expect(toast(page, /blocked the new window/)).toBeVisible();
|
||||
await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('what a window must not lose', () => {
|
||||
test('text whose save fails in the main window lands in its "Continue draft" slot', async ({ browser }) => {
|
||||
const subject = `Refused ${RUN}`;
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
await page.keyboard.press('c');
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
|
||||
const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click());
|
||||
await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
|
||||
await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000, intervals: POLL }).toBe(1);
|
||||
// Give the pop-out time to see the main window answer its ping.
|
||||
await popup.waitForTimeout(500);
|
||||
const typed = `${subject} typed`;
|
||||
await popup.locator('[data-testid="composer-subject"]').first().fill(typed);
|
||||
// The server stops taking saves, for the main window's rescue too.
|
||||
await page.context().route('**/jmap/**', async (route) => {
|
||||
if ((route.request().postData() ?? '').includes('Email/set')) await route.abort();
|
||||
else await route.continue();
|
||||
});
|
||||
let prompted = false;
|
||||
popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); });
|
||||
const closed = popup.waitForEvent('close');
|
||||
await popup.close({ runBeforeUnload: true });
|
||||
await closed;
|
||||
expect(prompted).toBe(false);
|
||||
await expect(toast(page, /save/i)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText('Continue draft').first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(typed).first()).toBeVisible();
|
||||
await page.context().unroute('**/jmap/**');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('editing a draft from a message window opens a live composer, and only then closes', async ({ browser }) => {
|
||||
const subject = `Edit ${RUN}`;
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const draftId = await alice.createDraft(subject, ENT.bob);
|
||||
const page = await appPage(browser, STANDALONE);
|
||||
const drafts = await alice.mailboxByRole('drafts');
|
||||
await page.goto(`/mail/folder/${drafts!.id}`);
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await expect(row(page, subject)).toBeVisible({ timeout: 60_000 });
|
||||
const message = await openPopout(page, async () => {
|
||||
await row(page, subject).click({ button: 'right' });
|
||||
await page.getByTestId('ctx-open-window').click();
|
||||
});
|
||||
await expect(message.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 });
|
||||
const closed = message.waitForEvent('close');
|
||||
const composer = await openPopout(message, async () => {
|
||||
await message.getByRole('button', { name: /^Edit/ }).first().click();
|
||||
});
|
||||
await expect(composer.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 60_000 });
|
||||
await closed;
|
||||
// Live: it still saves over the same draft, with the window that opened it gone.
|
||||
await composer.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`);
|
||||
await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000, intervals: POLL }).toBe(1);
|
||||
await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.id === draftId).length, { timeout: 20_000, intervals: POLL }).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the attachment bar', () => {
|
||||
test('stays one row in a narrow pane, with both buttons in reach', async ({ browser }) => {
|
||||
const subject = `Narrow ${RUN}`;
|
||||
await messageWithAttachments(subject);
|
||||
const page = await appPage(browser);
|
||||
await stubDirectoryPicker(page);
|
||||
await openInMain(page, subject);
|
||||
const save = page.locator('[data-testid="attachments-save-to-folder"]').first();
|
||||
await expect(save).toBeVisible({ timeout: 30_000 });
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
const pane = page.locator('[data-testid="reader-attachments"]').first();
|
||||
await expect(pane).toBeVisible();
|
||||
const row = save.locator('xpath=..');
|
||||
const rowBox = await row.boundingBox();
|
||||
const saveBox = await save.boundingBox();
|
||||
expect(rowBox!.height).toBeLessThan(48);
|
||||
expect(saveBox!.x + saveBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1);
|
||||
await expect(save).toHaveAttribute('aria-label', /folder/i);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the desktop app\'s behaviour', () => {
|
||||
test('plain-text mail can be selected, list rows can\'t', async ({ browser }) => {
|
||||
const subject = `Selectable ${RUN}`;
|
||||
await deliverToAlice(subject);
|
||||
const page = await appPage(browser, BRIDGE);
|
||||
await openInMain(page, subject);
|
||||
const body = page.locator('[data-plugin-source="email-body"]').first();
|
||||
await expect(body).toBeVisible({ timeout: 30_000 });
|
||||
expect(await body.evaluate((el) => getComputedStyle(el).userSelect)).toBe('text');
|
||||
expect(await row(page, subject).evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
|
||||
// A real selection, the way a user would make one.
|
||||
await body.click({ clickCount: 3 });
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? '')).toContain(`Body of ${subject}`);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('hangs on the bridge: a browser gets none of it', async ({ browser }) => {
|
||||
const plain = await appPage(browser);
|
||||
expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull();
|
||||
const button = plain.locator('button').first();
|
||||
expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none');
|
||||
await plain.context().close();
|
||||
|
||||
const app = await appPage(browser, BRIDGE);
|
||||
expect(await app.locator('html').getAttribute('data-desktop')).toBe('');
|
||||
const chrome = app.locator('button').first();
|
||||
expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none');
|
||||
expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none');
|
||||
await app.context().close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { desktopBootScript } from '@/lib/desktop/boot';
|
||||
import { startWindowSync } from '@/lib/desktop/window-sync';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
/** What another window of this browser writing the store looks like here. */
|
||||
function writeFromAnotherWindow(key: string, state: Record<string, unknown>) {
|
||||
const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
|
||||
const value = JSON.stringify({ ...current, state: { ...current.state, ...state } });
|
||||
localStorage.setItem(key, value);
|
||||
window.dispatchEvent(new StorageEvent('storage', { key, newValue: value }));
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('state across windows', () => {
|
||||
let stop: () => void = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
useThemeStore.setState({ theme: 'light' });
|
||||
useLocaleStore.setState({ locale: 'en' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop();
|
||||
stop = () => {};
|
||||
delete window.bulwarkDesktop;
|
||||
window.history.replaceState(null, '', '/');
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
});
|
||||
|
||||
function asPopout() {
|
||||
window.history.replaceState(null, '', '/en/popout/compose');
|
||||
stop = startWindowSync();
|
||||
}
|
||||
|
||||
it('follows a theme change without a reload, down to the page', async () => {
|
||||
asPopout();
|
||||
writeFromAnotherWindow('theme-storage', { theme: 'dark' });
|
||||
await settle();
|
||||
expect(useThemeStore.getState().theme).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => {
|
||||
asPopout();
|
||||
const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css);
|
||||
expect(pack).toBeTruthy();
|
||||
writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id });
|
||||
await settle();
|
||||
expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css);
|
||||
});
|
||||
|
||||
it('follows a language change without a reload', async () => {
|
||||
asPopout();
|
||||
writeFromAnotherWindow('locale-storage', { locale: 'de' });
|
||||
await settle();
|
||||
expect(useLocaleStore.getState().locale).toBe('de');
|
||||
});
|
||||
|
||||
it('follows a settings change without a reload', async () => {
|
||||
asPopout();
|
||||
const before = useSettingsStore.getState().plainTextMode;
|
||||
writeFromAnotherWindow('settings-storage', { plainTextMode: !before });
|
||||
await settle();
|
||||
expect(useSettingsStore.getState().plainTextMode).toBe(!before);
|
||||
});
|
||||
|
||||
it('also runs in the desktop app\'s main window', async () => {
|
||||
window.bulwarkDesktop = { windows: {} };
|
||||
stop = startWindowSync();
|
||||
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
|
||||
await settle();
|
||||
expect(useLocaleStore.getState().locale).toBe('fr');
|
||||
});
|
||||
|
||||
it('leaves a browser tab as it was', async () => {
|
||||
stop = startWindowSync();
|
||||
writeFromAnotherWindow('locale-storage', { locale: 'fr' });
|
||||
await settle();
|
||||
expect(useLocaleStore.getState().locale).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the app-like behaviour', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8');
|
||||
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? [];
|
||||
|
||||
it('hangs entirely on html[data-desktop], so browsers do not change', () => {
|
||||
expect(rules.length).toBeGreaterThan(0);
|
||||
for (const rule of rules) {
|
||||
const selector = rule.slice(0, rule.indexOf('{'));
|
||||
// Every selector of the list; commas inside :is() don't split it.
|
||||
const parts = selector.split(/,(?![^(]*\))/);
|
||||
for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('covers UI chrome and what the spec names', () => {
|
||||
expect(css).toMatch(/user-select: none/);
|
||||
expect(css).toMatch(/cursor: default/);
|
||||
expect(css).toMatch(/overscroll-behavior: none/);
|
||||
expect(css).toMatch(/-webkit-user-drag: none/);
|
||||
});
|
||||
|
||||
it('keeps fields selectable and what the app drags draggable', () => {
|
||||
expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/);
|
||||
expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/);
|
||||
});
|
||||
|
||||
it('does not switch selection off for the whole page, so message content stays selectable', () => {
|
||||
expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/);
|
||||
});
|
||||
|
||||
it('matches list rows only, not the plain-text body that carries the same data attribute', () => {
|
||||
// email-viewer.tsx and thread-conversation-view.tsx put data-email-id on the body.
|
||||
for (const file of ['components/email/email-viewer.tsx', 'components/email/thread-conversation-view.tsx']) {
|
||||
const source = readFileSync(join(process.cwd(), file), 'utf8').replace(/\r\n/g, '\n');
|
||||
expect(source, file).toMatch(/data-plugin-source="email-body"\n\s*data-email-id=/);
|
||||
}
|
||||
expect(css).not.toContain('data-email-id');
|
||||
});
|
||||
|
||||
it('keeps a plain-text body selectable wherever it sits', () => {
|
||||
expect(css).toMatch(/\[data-plugin-source="email-body"\],\s*html\[data-desktop\] \[data-plugin-source="email-body"\] \*\s*\{\s*-webkit-user-select: text;\s*user-select: text;/);
|
||||
});
|
||||
|
||||
function runBoot() {
|
||||
new Function(desktopBootScript())();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete window.bulwarkDesktop;
|
||||
document.documentElement.removeAttribute('data-desktop');
|
||||
});
|
||||
|
||||
it('marks the page before the first paint when the bridge is there', () => {
|
||||
window.bulwarkDesktop = {};
|
||||
runBoot();
|
||||
expect(document.documentElement.hasAttribute('data-desktop')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark a browser', () => {
|
||||
runBoot();
|
||||
expect(document.documentElement.hasAttribute('data-desktop')).toBe(false);
|
||||
});
|
||||
|
||||
it('is in the root layout\'s boot script', () => {
|
||||
const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8');
|
||||
expect(layout).toContain('${desktopBootScript()}');
|
||||
expect(layout).toContain('../desktop.css');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A same-process BroadcastChannel for tests: what one instance posts reaches
|
||||
* every other open instance of that name, on a later task, never the sender.
|
||||
*/
|
||||
export class FakeBroadcastChannel {
|
||||
static open = new Set<FakeBroadcastChannel>();
|
||||
static reset() {
|
||||
FakeBroadcastChannel.open.clear();
|
||||
}
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
constructor(public name: string) {
|
||||
FakeBroadcastChannel.open.add(this);
|
||||
}
|
||||
postMessage(data: unknown) {
|
||||
const copy = JSON.parse(JSON.stringify(data));
|
||||
for (const other of [...FakeBroadcastChannel.open]) {
|
||||
if (other === this || other.name !== this.name) continue;
|
||||
setTimeout(() => {
|
||||
if (FakeBroadcastChannel.open.has(other)) other.onmessage?.({ data: copy } as MessageEvent);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
close() {
|
||||
FakeBroadcastChannel.open.delete(this);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
inlineScriptHashes,
|
||||
installStalwartFetchShim,
|
||||
isReservedStalwartSegment,
|
||||
LITE_REMOVED_PATHS,
|
||||
listZipEntries,
|
||||
makeBuildId,
|
||||
normalizeBasePath,
|
||||
@@ -352,6 +353,8 @@ describe('prepare / postbuild / verify against a fake checkout', () => {
|
||||
expect(plan).toContain('proxy.ts');
|
||||
expect(plan).toContain('app/api');
|
||||
expect(plan).toContain('app/(main)/admin');
|
||||
// A static export can't list every message id; Lite doesn't offer pop-outs.
|
||||
expect(LITE_REMOVED_PATHS).toContain('app/(main)/[locale]/popout');
|
||||
expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx');
|
||||
expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts');
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from './fixtures/fake-broadcast-channel';
|
||||
import { isPopoutPath, mainWindowStorage } from '@/lib/popout/window-role';
|
||||
|
||||
// Pop-out windows: which paths are one, when they are on offer, how they open,
|
||||
// and how a draft moves to one.
|
||||
|
||||
function stubDisplay({ standalone = true, fine = true }: { standalone?: boolean; fine?: boolean } = {}) {
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches: query.includes('pointer: fine') ? fine : query.includes('display-mode: standalone') ? standalone : false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
window.matchMedia = globalThis.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
delete window.bulwarkDesktop;
|
||||
Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('isPopoutPath', () => {
|
||||
it('knows the two routes, with or without locale and mount prefix', () => {
|
||||
for (const path of ['/popout/compose', '/en/popout/compose', '/zh-TW/popout/message/abc', '/webmail/de/popout/message/abc', '/popout/compose/']) {
|
||||
expect(isPopoutPath(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not take other pages for one', () => {
|
||||
// A folder in Files can have any name, and a mail folder too.
|
||||
for (const path of ['/', '/mail', '/files/popout/compose', '/en/mail/folder/popout', '/popout', '/popout/message', '/popout/message/a/b', '/en/files/popout/message/x', '/files/en/popout/compose', '/some/folder/popout/compose']) {
|
||||
expect(isPopoutPath(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPopoutPath under a mount named like a page (NEXT_PUBLIC_BASE_PATH)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('takes the mount off first', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/mail');
|
||||
vi.resetModules();
|
||||
const { isPopoutPath: underMail } = await import('@/lib/popout/window-role');
|
||||
expect(underMail('/mail/popout/compose')).toBe(true);
|
||||
expect(underMail('/mail/de/popout/message/abc')).toBe(true);
|
||||
// Under that mount, its files page stays a files page.
|
||||
expect(underMail('/mail/files/popout/compose')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mainWindowStorage', () => {
|
||||
it('lets a main window write, and a pop-out only read', () => {
|
||||
localStorage.setItem('account-registry', 'before');
|
||||
const storage = mainWindowStorage();
|
||||
|
||||
storage.setItem('account-registry', 'main');
|
||||
expect(localStorage.getItem('account-registry')).toBe('main');
|
||||
|
||||
window.history.replaceState(null, '', '/en/popout/compose');
|
||||
expect(storage.getItem('account-registry')).toBe('main');
|
||||
storage.setItem('account-registry', 'popout');
|
||||
storage.removeItem('account-registry');
|
||||
expect(localStorage.getItem('account-registry')).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('popoutsAvailable', () => {
|
||||
async function available() {
|
||||
const { popoutsAvailable } = await import('@/lib/popout/support');
|
||||
return popoutsAvailable();
|
||||
}
|
||||
|
||||
it('is off in a browser tab: the app behaves as it always has', async () => {
|
||||
stubDisplay({ standalone: false });
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('is on in an installed app on a device with a pointer', async () => {
|
||||
stubDisplay();
|
||||
expect(await available()).toBe(true);
|
||||
});
|
||||
|
||||
it('is off in an installed app on a touch device, where window.open leaves the app', async () => {
|
||||
stubDisplay({ fine: false });
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('asks the bridge too: display-mode is `browser` in Electron', async () => {
|
||||
stubDisplay({ standalone: false });
|
||||
window.bulwarkDesktop = { windows: {} };
|
||||
expect(await available()).toBe(true);
|
||||
});
|
||||
|
||||
it('is off without Web Locks or BroadcastChannel, which keep the session safe across windows', async () => {
|
||||
stubDisplay();
|
||||
Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true });
|
||||
expect(await available()).toBe(false);
|
||||
Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true });
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('is off where sessions can\'t be sealed: a page not in a secure context has no crypto.subtle', async () => {
|
||||
stubDisplay();
|
||||
expect(await available()).toBe(true);
|
||||
vi.stubGlobal('crypto', {});
|
||||
expect(await available()).toBe(false);
|
||||
});
|
||||
|
||||
it('is off in Lite, which has no such route', async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock('@/lib/lite', async (original) => ({ ...await original<object>(), IS_LITE: true }));
|
||||
stubDisplay();
|
||||
const { popoutsAvailable } = await import('@/lib/popout/support');
|
||||
expect(popoutsAvailable()).toBe(false);
|
||||
vi.doUnmock('@/lib/lite');
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening windows', () => {
|
||||
it('opens a message as a popup in the app, with its login and account', async () => {
|
||||
const { openMessageWindow } = await import('@/lib/popout/open');
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn(), closed: false } as unknown as Window);
|
||||
expect(openMessageWindow({ emailId: 'm/1', localAccountId: 'login-b', ownerAccountId: 'group' })).toBe(true);
|
||||
const [url, name, features] = open.mock.calls[0];
|
||||
expect(url).toBe('/popout/message/m%2F1?account=login-b&owner=group');
|
||||
expect(name).toBe('bulwark-message-m_1');
|
||||
expect(features).toMatch(/^popup=yes,width=\d+,height=\d+,left=\d+,top=\d+$/);
|
||||
// In an installed app noopener sends the window to the system browser.
|
||||
expect(features).not.toContain('noopener');
|
||||
});
|
||||
|
||||
it('focuses the window a message already has, without loading it again', async () => {
|
||||
const { openMessageWindow } = await import('@/lib/popout/open');
|
||||
const focus = vi.fn();
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue({ focus, closed: false } as unknown as Window);
|
||||
openMessageWindow({ emailId: 'twice' });
|
||||
openMessageWindow({ emailId: 'twice' });
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(focus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens it again once that window was closed', async () => {
|
||||
const { openMessageWindow } = await import('@/lib/popout/open');
|
||||
const first = { focus: vi.fn(), closed: false };
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(first as unknown as Window);
|
||||
openMessageWindow({ emailId: 'reopened' });
|
||||
first.closed = true;
|
||||
openMessageWindow({ emailId: 'reopened' });
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('leaves it to the bridge when the bridge opens windows, under the same name', async () => {
|
||||
const { openMessageWindow } = await import('@/lib/popout/open');
|
||||
const bridgeOpen = vi.fn();
|
||||
window.bulwarkDesktop = { windows: { open: bridgeOpen } };
|
||||
const open = vi.spyOn(window, 'open');
|
||||
expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
|
||||
expect(openMessageWindow({ emailId: 'bridged' })).toBe(true);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(bridgeOpen.mock.calls[0][1]).toMatchObject({ name: 'bulwark-message-bridged' });
|
||||
expect(bridgeOpen.mock.calls[1][1].name).toBe(bridgeOpen.mock.calls[0][1].name);
|
||||
});
|
||||
|
||||
it('says false when a blocker stops the window', async () => {
|
||||
const { openComposerWindow, openMessageWindow } = await import('@/lib/popout/open');
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
expect(openMessageWindow({ emailId: 'blocked' })).toBe(false);
|
||||
expect(openComposerWindow()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moving a draft to a pop-out', () => {
|
||||
const payload = { mode: 'reply' as const, initialData: { draftId: 'saved-3', subject: 'Hi' } as never };
|
||||
const settle = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// The module keeps its channel while a handoff is pending.
|
||||
beforeEach(() => vi.resetModules());
|
||||
|
||||
it('hands the draft to the window that asks, once it is ready, under the same draft id', async () => {
|
||||
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
const handoff = createComposeHandoff()!;
|
||||
const received = receiveComposeHandoff(handoff.id);
|
||||
await settle();
|
||||
// The pop-out is open and waiting; the save hasn't finished.
|
||||
let done = false;
|
||||
void received.then(() => { done = true; });
|
||||
await settle();
|
||||
expect(done).toBe(false);
|
||||
|
||||
void handoff.complete(payload);
|
||||
await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } });
|
||||
});
|
||||
|
||||
it('lets the sender go only once the composer is on screen there', async () => {
|
||||
const { acknowledgeComposeHandoff, createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
const handoff = createComposeHandoff()!;
|
||||
void receiveComposeHandoff(handoff.id);
|
||||
await settle();
|
||||
let shown: boolean | undefined;
|
||||
void handoff.complete(payload).then((result) => { shown = result; });
|
||||
await settle();
|
||||
expect(shown).toBeUndefined();
|
||||
acknowledgeComposeHandoff(handoff.id);
|
||||
await settle();
|
||||
expect(shown).toBe(true);
|
||||
});
|
||||
|
||||
it('tells the sender when the composer never shows, and it can still call it off', async () => {
|
||||
const { createComposeHandoff, receiveComposeHandoff, watchHandoffCancel } = await import('@/lib/popout/compose-handoff');
|
||||
const handoff = createComposeHandoff()!;
|
||||
void receiveComposeHandoff(handoff.id);
|
||||
await settle();
|
||||
const cancelled = vi.fn();
|
||||
const stop = watchHandoffCancel(handoff.id, cancelled);
|
||||
await expect(handoff.complete(payload, 20)).resolves.toBe(false);
|
||||
handoff.cancel();
|
||||
await settle();
|
||||
expect(cancelled).toHaveBeenCalledTimes(1);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('keeps delivering when the message window that opened it is still there', async () => {
|
||||
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
const handoff = createComposeHandoff()!;
|
||||
// The draft is ready before the new window asks for it.
|
||||
void handoff.complete(payload);
|
||||
await settle();
|
||||
await expect(receiveComposeHandoff(handoff.id)).resolves.toMatchObject({ mode: 'reply' });
|
||||
});
|
||||
|
||||
it('tells the pop-out when the move is called off', async () => {
|
||||
const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
const handoff = createComposeHandoff()!;
|
||||
const received = receiveComposeHandoff(handoff.id);
|
||||
await settle();
|
||||
handoff.cancel();
|
||||
await expect(received).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('gives up when nothing comes in time', async () => {
|
||||
const { receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
await expect(receiveComposeHandoff('nobody-has-this', 10)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('starts a reload of the pop-out from what it holds, with no opener needed', async () => {
|
||||
const { rememberComposePayload, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff');
|
||||
rememberComposePayload('kept', payload);
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
await expect(receiveComposeHandoff('kept')).resolves.toMatchObject({ initialData: { draftId: 'saved-3' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('asking a main window for the session', () => {
|
||||
it('takes the first answer and goes on without one after the timeout', async () => {
|
||||
const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
|
||||
const { sealFor } = await import('@/lib/popout/sealed');
|
||||
await expect(requestWindowSession({}, 10)).resolves.toBeNull();
|
||||
|
||||
const main = openPopoutChannel((message) => {
|
||||
if (message.type === 'session-request') {
|
||||
void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [] })
|
||||
.then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
|
||||
}
|
||||
});
|
||||
await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] });
|
||||
main?.close();
|
||||
});
|
||||
|
||||
it('puts no credential on the channel: a listener hears neither the header nor the refused one', async () => {
|
||||
const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel');
|
||||
const { sealFor } = await import('@/lib/popout/sealed');
|
||||
const secret = 'Bearer very-secret-token';
|
||||
const refused = 'Bearer refused-secret-token';
|
||||
const heard: unknown[] = [];
|
||||
const eavesdropper = openPopoutChannel((message) => heard.push(message));
|
||||
const main = openPopoutChannel((message) => {
|
||||
if (message.type !== 'session-request') return;
|
||||
void sealFor(message.publicKey, { activeAccountId: 'a', sessions: [{ accountId: 'a', authHeader: secret }] })
|
||||
.then((sealed) => main?.post({ type: 'session-answer', requestId: message.requestId, sealed }));
|
||||
});
|
||||
const answer = await requestWindowSession({ accountId: 'a', rejectedAuthHeader: refused }, 1000);
|
||||
expect(answer?.sessions[0].authHeader).toBe(secret);
|
||||
expect(heard.length).toBeGreaterThanOrEqual(2);
|
||||
expect(JSON.stringify(heard)).not.toContain('secret');
|
||||
main?.close();
|
||||
eavesdropper?.close();
|
||||
});
|
||||
|
||||
it('a listener can\'t open an answer meant for another window', async () => {
|
||||
const { createSessionOpener, sealFor } = await import('@/lib/popout/sealed');
|
||||
const asker = await createSessionOpener();
|
||||
const other = await createSessionOpener();
|
||||
const sealed = await sealFor(asker.publicKey, { secret: true });
|
||||
await expect(asker.open(sealed)).resolves.toEqual({ secret: true });
|
||||
await expect(other.open(sealed)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('text a closing pop-out could not save', () => {
|
||||
it('waits for the mail view, marked unsaved, oldest first', async () => {
|
||||
vi.resetModules();
|
||||
sessionStorage.clear();
|
||||
const { keepRescuedDraft, onRescuedDraft, takeRescuedDraft } = await import('@/lib/popout/rescued-drafts');
|
||||
const told = vi.fn();
|
||||
const stop = onRescuedDraft(told);
|
||||
keepRescuedDraft({ subject: 'First' } as never);
|
||||
keepRescuedDraft({ subject: 'Second' } as never);
|
||||
stop();
|
||||
expect(told).toHaveBeenCalledTimes(2);
|
||||
expect(takeRescuedDraft()).toMatchObject({ subject: 'First', unsaved: true });
|
||||
expect(takeRescuedDraft()).toMatchObject({ subject: 'Second' });
|
||||
expect(takeRescuedDraft()).toBeNull();
|
||||
});
|
||||
|
||||
it('survives a reload of the main window', async () => {
|
||||
vi.resetModules();
|
||||
sessionStorage.clear();
|
||||
const first = await import('@/lib/popout/rescued-drafts');
|
||||
first.keepRescuedDraft({ subject: 'Kept' } as never);
|
||||
vi.resetModules();
|
||||
const second = await import('@/lib/popout/rescued-drafts');
|
||||
expect(second.takeRescuedDraft()).toMatchObject({ subject: 'Kept' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('every display-mode check knows the overlay', () => {
|
||||
it('goes through lib/standalone-display.ts', () => {
|
||||
for (const file of ['components/protocol/mailto-protocol-client.tsx', 'components/protocol/webcal-protocol-client.tsx', 'components/protocol/protocol-launch-handler-provider.tsx', 'lib/protocol-handlers/session.ts']) {
|
||||
const source = readFileSync(join(process.cwd(), file), 'utf8');
|
||||
expect(source, file).not.toContain('display-mode: standalone');
|
||||
expect(source, file).toContain('isStandaloneDisplayMode');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canPickFolder, createUniqueNamer, isPickerCancelled, saveFilesToFolder, type FolderHandle } from '@/lib/attachments/save-to-folder';
|
||||
|
||||
class FakeFolder implements FolderHandle {
|
||||
name = 'Downloads';
|
||||
files = new Map<string, string>();
|
||||
failWriteOf: string | null = null;
|
||||
removed: string[] = [];
|
||||
refused = new Set<string>();
|
||||
|
||||
async getFileHandle(name: string, options?: { create?: boolean }) {
|
||||
// What Windows and Chromium's File System Access refuse.
|
||||
const stem = name.split('.')[0].toLowerCase();
|
||||
if (/^(con|prn|aux|nul|com\d|lpt\d)$/.test(stem)
|
||||
|| /\.(lnk|url|scf|local)$/i.test(name)
|
||||
|| name.toLowerCase() === 'desktop.ini'
|
||||
|| new TextEncoder().encode(name).length > 255
|
||||
|| this.refused.has(name)) {
|
||||
throw new TypeError(`Name is not allowed: ${name}`);
|
||||
}
|
||||
if (!this.files.has(name)) {
|
||||
if (!options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' });
|
||||
this.files.set(name, '');
|
||||
}
|
||||
return {
|
||||
createWritable: async () => ({
|
||||
write: async (data: unknown) => {
|
||||
if (this.failWriteOf === name) throw Object.assign(new Error('disk full'), { name: 'QuotaExceededError' });
|
||||
this.files.set(name, String(data));
|
||||
},
|
||||
close: async () => {},
|
||||
abort: async () => {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async removeEntry(name: string) {
|
||||
this.removed.push(name);
|
||||
this.files.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const item = (name: string, content: string | null = name) => ({ name, read: async () => content as unknown as BufferSource | null });
|
||||
const always = () => true;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker;
|
||||
});
|
||||
|
||||
describe('saveFilesToFolder', () => {
|
||||
it('writes each file and says how many', async () => {
|
||||
const folder = new FakeFolder();
|
||||
const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt')], always);
|
||||
expect(result).toEqual({ folder: 'Downloads', total: 2, saved: 2, skipped: 0, stopped: null });
|
||||
expect(folder.files.get('a.txt')).toBe('a.txt');
|
||||
});
|
||||
|
||||
it('gives same-named attachments their own files', async () => {
|
||||
const folder = new FakeFolder();
|
||||
await saveFilesToFolder(folder, [item('scan.pdf', 'one'), item('scan.pdf', 'two'), item('scan.pdf', 'three')], always);
|
||||
expect([...folder.files.entries()]).toEqual([['scan.pdf', 'one'], ['scan (1).pdf', 'two'], ['scan (2).pdf', 'three']]);
|
||||
});
|
||||
|
||||
it('never overwrites a file already in the folder', async () => {
|
||||
const folder = new FakeFolder();
|
||||
folder.files.set('report.pdf', 'precious');
|
||||
await saveFilesToFolder(folder, [item('report.pdf', 'new')], always);
|
||||
expect(folder.files.get('report.pdf')).toBe('precious');
|
||||
expect(folder.files.get('report (1).pdf')).toBe('new');
|
||||
});
|
||||
|
||||
it('stops at a failed write, keeps what is saved, and leaves no empty file', async () => {
|
||||
const folder = new FakeFolder();
|
||||
folder.failWriteOf = 'b.txt';
|
||||
const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt'), item('c.txt')], always);
|
||||
expect(result).toMatchObject({ saved: 1, total: 3, stopped: 'write' });
|
||||
expect([...folder.files.keys()]).toEqual(['a.txt']);
|
||||
expect(folder.removed).toEqual(['b.txt']);
|
||||
});
|
||||
|
||||
it('skips a file that cannot be fetched and goes on, as the zip does', async () => {
|
||||
const folder = new FakeFolder();
|
||||
const broken = { name: 'gone.bin', read: async () => { throw new Error('404'); } };
|
||||
const result = await saveFilesToFolder(folder, [item('a.txt'), broken, item('c.txt')], always);
|
||||
expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
|
||||
});
|
||||
|
||||
it('asks the policy before every file, and a policy turning on stops the rest', async () => {
|
||||
const folder = new FakeFolder();
|
||||
let asked = 0;
|
||||
// On for the first file's two checks, then off.
|
||||
const allowed = () => ++asked <= 2;
|
||||
const read = vi.fn(async () => 'x' as unknown as BufferSource);
|
||||
const result = await saveFilesToFolder(folder, [{ name: 'a', read }, { name: 'b', read }], allowed);
|
||||
expect(result).toMatchObject({ saved: 1, stopped: 'policy' });
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect([...folder.files.keys()]).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('writes nothing fetched while the policy turned on', async () => {
|
||||
const folder = new FakeFolder();
|
||||
let on = true;
|
||||
const read = vi.fn(async () => { on = false; return 'x' as unknown as BufferSource; });
|
||||
const result = await saveFilesToFolder(folder, [{ name: 'a', read }], () => on);
|
||||
expect(result).toMatchObject({ saved: 0, stopped: 'policy' });
|
||||
expect(folder.files.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a sender\'s path out of the file name', async () => {
|
||||
const folder = new FakeFolder();
|
||||
await saveFilesToFolder(folder, [item('../../evil.exe'), item('C:\\Windows\\win.ini'), item('a:b?.txt'), item('...')], always);
|
||||
expect([...folder.files.keys()]).toEqual(['evil.exe', 'win.ini', 'a_b_.txt', 'attachment']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('names a file system or the shell keeps for itself', () => {
|
||||
it('renames them, so the attachment is still saved', async () => {
|
||||
const folder = new FakeFolder();
|
||||
const names = ['CON', 'NUL.txt', 'com1.tar.gz', 'desktop.ini', 'x.lnk', 'x.url', 'x.scf', 'X.LNK'];
|
||||
const result = await saveFilesToFolder(folder, names.map((name) => item(name)), always);
|
||||
expect(result).toMatchObject({ saved: names.length, skipped: 0, stopped: null });
|
||||
expect([...folder.files.keys()]).toEqual(['_CON', '_NUL.txt', '_com1.tar.gz', '_desktop.ini', 'x.lnk.txt', 'x.url.txt', 'x.scf.txt', 'X.LNK.txt']);
|
||||
});
|
||||
|
||||
it('leaves names that only look like them', async () => {
|
||||
const folder = new FakeFolder();
|
||||
await saveFilesToFolder(folder, ['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf'].map((name) => item(name)), always);
|
||||
expect([...folder.files.keys()]).toEqual(['context.txt', 'console.log', 'nullable.ts', 'lnk.pdf']);
|
||||
});
|
||||
|
||||
it('shortens a name over the limit, in bytes, keeping the extension and whole characters', async () => {
|
||||
const folder = new FakeFolder();
|
||||
const long = `${'ü'.repeat(200)}.pdf`;
|
||||
const result = await saveFilesToFolder(folder, [item(long), item(long)], always);
|
||||
expect(result.saved).toBe(2);
|
||||
const [first, second] = [...folder.files.keys()];
|
||||
expect(first.endsWith('.pdf')).toBe(true);
|
||||
expect(first).not.toContain('\uFFFD');
|
||||
expect(new TextEncoder().encode(first).length).toBeLessThanOrEqual(255);
|
||||
expect(new TextEncoder().encode(second).length).toBeLessThanOrEqual(255);
|
||||
expect(second.endsWith(' (1).pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips one name the folder still refuses and goes on to the rest', async () => {
|
||||
const folder = new FakeFolder();
|
||||
folder.refused.add('odd.bin');
|
||||
const result = await saveFilesToFolder(folder, [item('a.txt'), item('odd.bin'), item('c.txt')], always);
|
||||
expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null });
|
||||
expect([...folder.files.keys()]).toEqual(['a.txt', 'c.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the helpers', () => {
|
||||
it('sees the picker only where the browser has it', () => {
|
||||
expect(canPickFolder()).toBe(false);
|
||||
(window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = () => {};
|
||||
expect(canPickFolder()).toBe(true);
|
||||
});
|
||||
|
||||
it('tells a closed picker from a failure', () => {
|
||||
expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'AbortError' }))).toBe(true);
|
||||
expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'SecurityError' }))).toBe(false);
|
||||
expect(isPickerCancelled(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('numbers zip entries like before', () => {
|
||||
const name = createUniqueNamer();
|
||||
expect(['a.txt', 'a.txt', 'a.txt', '', ''].map(name)).toEqual(['a.txt', 'a (1).txt', 'a (2).txt', 'attachment', 'attachment (1)']);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,45 @@ describe('service worker with offline mode on', () => {
|
||||
expect(await deep!.text()).toBe('<html>mail page</html>');
|
||||
});
|
||||
|
||||
it('keeps pop-out pages, and starts a message it never saw from the page kept for it', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
let online = true;
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (!online) throw new TypeError('Failed to fetch');
|
||||
return sameOrigin(`<html>${new URL(typeof url === 'string' ? url : (url as Request).url).pathname}</html>`);
|
||||
});
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock as never });
|
||||
await settle();
|
||||
for (const path of ['/popout/compose', '/popout/message/_', '/']) {
|
||||
const page = await worker.request({ url: `${ORIGIN}${path}`, mode: 'navigate' });
|
||||
expect(page, path).not.toBeNull();
|
||||
}
|
||||
await settle();
|
||||
|
||||
online = false;
|
||||
const message = await worker.request({ url: `${ORIGIN}/popout/message/never-seen`, mode: 'navigate' });
|
||||
expect(await message!.text()).toBe('<html>/popout/message/_</html>');
|
||||
const compose = await worker.request({ url: `${ORIGIN}/popout/compose?handoff=x`, mode: 'navigate' });
|
||||
expect(await compose!.text()).toBe('<html>/popout/compose</html>');
|
||||
});
|
||||
|
||||
it('never puts the whole app into a pop-out\'s window', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
let online = true;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
if (!online) throw new TypeError('Failed to fetch');
|
||||
return sameOrigin('<html>the whole app</html>');
|
||||
});
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
await worker.request({ url: `${ORIGIN}/`, mode: 'navigate' });
|
||||
await settle();
|
||||
online = false;
|
||||
await expect(worker.request({ url: `${ORIGIN}/popout/compose`, mode: 'navigate' })).rejects.toThrow('Failed to fetch');
|
||||
});
|
||||
|
||||
it('serves a chunk of an older build from that build\'s cache, and keeps new ones per build', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock';
|
||||
|
||||
/** Web Locks with one holder at a time and waiters in order, like the browser's. */
|
||||
function fakeLocks() {
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
const held = new Set<string>();
|
||||
const names: string[] = [];
|
||||
return {
|
||||
names,
|
||||
request(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => unknown) {
|
||||
names.push(name);
|
||||
if (options.ifAvailable && held.has(name)) return Promise.resolve(callback(null));
|
||||
const previous = queues.get(name) ?? Promise.resolve();
|
||||
const run = new Promise((resolve, reject) => {
|
||||
let aborted = false;
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
});
|
||||
void previous.catch(() => {}).then(() => {
|
||||
if (aborted) return;
|
||||
held.add(name);
|
||||
Promise.resolve(callback({})).then(resolve, reject).finally(() => held.delete(name));
|
||||
});
|
||||
});
|
||||
queues.set(name, run.catch(() => {}));
|
||||
return run;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubLocks(locks: unknown) {
|
||||
Object.defineProperty(navigator, 'locks', { value: locks, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stubLocks(undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('withTokenLock', () => {
|
||||
it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => {
|
||||
stubLocks(fakeLocks());
|
||||
const order: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const first = withTokenLock(1, () => new Promise<string>((resolve) => {
|
||||
order.push('first starts');
|
||||
releaseFirst = () => {
|
||||
order.push('first ends');
|
||||
resolve('a');
|
||||
};
|
||||
}));
|
||||
const second = withTokenLock(1, async () => {
|
||||
order.push('second starts');
|
||||
return 'b';
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(order).toEqual(['first starts']);
|
||||
releaseFirst();
|
||||
await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']);
|
||||
expect(order).toEqual(['first starts', 'first ends', 'second starts']);
|
||||
});
|
||||
|
||||
it('keeps slots apart: each login has its own refresh token', async () => {
|
||||
const locks = fakeLocks();
|
||||
stubLocks(locks);
|
||||
await withTokenLock(0, async () => 'x');
|
||||
await withTokenLock(2, async () => 'y');
|
||||
expect(new Set(locks.names)).toEqual(new Set([tokenLockName(0), tokenLockName(2)]));
|
||||
expect(tokenLockName(0)).not.toBe(tokenLockName(2));
|
||||
});
|
||||
|
||||
it('runs at once without Web Locks', async () => {
|
||||
await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done');
|
||||
});
|
||||
|
||||
it('goes ahead when another window holds the lock too long, rather than never renewing', async () => {
|
||||
stubLocks(fakeLocks());
|
||||
void withTokenLock(1, () => new Promise(() => {}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await expect(withTokenLock(1, async () => 'went ahead', { waitMs: 10 })).resolves.toBe('went ahead');
|
||||
});
|
||||
|
||||
it('says when it had to wait for another window, and when it did not', async () => {
|
||||
stubLocks(fakeLocks());
|
||||
const turns: boolean[] = [];
|
||||
let release = () => {};
|
||||
const first = withTokenLock(1, ({ contended }) => {
|
||||
turns.push(contended);
|
||||
return new Promise<void>((resolve) => { release = resolve; });
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = withTokenLock(1, async ({ contended }) => { turns.push(contended); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(turns).toEqual([false, true]);
|
||||
await withTokenLock(1, async ({ contended }) => { turns.push(contended); });
|
||||
expect(turns).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('does nothing when the caller calls it off during the wait: a sign-out', async () => {
|
||||
stubLocks(fakeLocks());
|
||||
void withTokenLock(1, () => new Promise(() => {}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const signOut = new AbortController();
|
||||
const work = vi.fn(async () => 'renewed');
|
||||
const queued = withTokenLock(1, work, { signal: signOut.signal });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
signOut.abort();
|
||||
await expect(queued).rejects.toThrow();
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands the work\'s own error to the caller and does not run it twice', async () => {
|
||||
stubLocks(fakeLocks());
|
||||
const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||
await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch');
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WindowTitleBar } from '@/components/layout/window-title-bar';
|
||||
import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay';
|
||||
|
||||
class FakeOverlay extends EventTarget {
|
||||
visible = true;
|
||||
rect = { x: 0, y: 0, width: 867, height: 40 };
|
||||
getTitlebarAreaRect() {
|
||||
return this.rect;
|
||||
}
|
||||
change(next: Partial<FakeOverlay>) {
|
||||
Object.assign(this, next);
|
||||
this.dispatchEvent(new Event('geometrychange'));
|
||||
}
|
||||
}
|
||||
|
||||
function installOverlay(overlay: FakeOverlay | undefined) {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
||||
}
|
||||
|
||||
const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR);
|
||||
const strip = () => document.querySelector<HTMLElement>('[data-window-title-bar]');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
installOverlay(undefined);
|
||||
document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
});
|
||||
|
||||
describe('the title bar', () => {
|
||||
it('shows nothing and touches nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
expect(readTitlebarArea()).toBeNull();
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('shows nothing when the overlay is there but hidden', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
overlay.visible = false;
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('draws the strip next to the controls and pushes the page down', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'Inbox - Mail';
|
||||
render(<WindowTitleBar />);
|
||||
const bar = strip()!;
|
||||
expect(bar.style.width).toBe('867px');
|
||||
expect(bar.style.height).toBe('40px');
|
||||
expect(bar.textContent).toBe('Inbox - Mail');
|
||||
expect(bar.classList.contains('window-title-bar')).toBe(true);
|
||||
expect(inset()).toBe('40px');
|
||||
});
|
||||
|
||||
it('stays out of the accessibility tree and the tab order', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } }));
|
||||
expect(strip()!.style.left).toBe('72px');
|
||||
expect(inset()).toBe('32px');
|
||||
act(() => overlay.change({ visible: false }));
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('follows the window title as the surface changes it', async () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'First';
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => {
|
||||
document.title = 'Second';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(strip()!.textContent).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page roots and the stylesheet', () => {
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
||||
|
||||
it('is 0 without an overlay, and exactly the old safe area then', () => {
|
||||
expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);');
|
||||
expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));');
|
||||
});
|
||||
|
||||
it('does not key on the display-mode media query, which is false in Electron', () => {
|
||||
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
||||
});
|
||||
|
||||
it('makes the strip the part that drags the window', () => {
|
||||
expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/);
|
||||
});
|
||||
|
||||
function tsxFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (name === '__tests__' || name === 'node_modules') return [];
|
||||
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
||||
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
||||
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
||||
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
||||
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* "Save all attachments to a folder", with the File System Access API.
|
||||
*
|
||||
* These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of
|
||||
* the device policies, so the caller's `allowed` is asked before each file:
|
||||
* a policy that turns on while the files are written stops the rest.
|
||||
*
|
||||
* The folder handle is an interface, not the DOM type, so the desktop bridge
|
||||
* or a test can supply its own.
|
||||
*/
|
||||
|
||||
export interface WritableFile {
|
||||
write(data: Blob | BufferSource): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
abort?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FolderFile {
|
||||
createWritable(): Promise<WritableFile>;
|
||||
}
|
||||
|
||||
export interface FolderHandle {
|
||||
name: string;
|
||||
getFileHandle(name: string, options?: { create?: boolean }): Promise<FolderFile>;
|
||||
removeEntry?(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise<FolderHandle>;
|
||||
|
||||
/** Whether this browser can ask for a folder. Without it the zip download is all there is. */
|
||||
export function canPickFolder(): boolean {
|
||||
return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks where to save. Call it straight from the click, before any await: the
|
||||
* browser only shows the picker for a user action. Rejects with an AbortError
|
||||
* when the user closes the picker (see isPickerCancelled).
|
||||
*/
|
||||
export function pickFolder(): Promise<FolderHandle> {
|
||||
const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker;
|
||||
return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' });
|
||||
}
|
||||
|
||||
export function isPickerCancelled(error: unknown): boolean {
|
||||
return (error as { name?: unknown } | null)?.name === 'AbortError';
|
||||
}
|
||||
|
||||
/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */
|
||||
function numberedName(base: string, n: number): string {
|
||||
if (n === 0) return base;
|
||||
const dot = base.lastIndexOf('.');
|
||||
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||
const ext = dot > 0 ? base.slice(dot) : '';
|
||||
return `${stem} (${n})${ext}`;
|
||||
}
|
||||
|
||||
/** Names unique within one batch, for the zip's entries. */
|
||||
export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string {
|
||||
const used = new Set<string>();
|
||||
return (raw) => {
|
||||
const base = raw || fallback;
|
||||
let n = 0;
|
||||
while (used.has(numberedName(base, n))) n++;
|
||||
const name = numberedName(base, n);
|
||||
used.add(name);
|
||||
return name;
|
||||
};
|
||||
}
|
||||
|
||||
/** Names Windows keeps for devices, with any extension: NUL.txt is NUL. */
|
||||
const RESERVED_STEM = /^(con|prn|aux|nul|clock\$|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
||||
/** Names and extensions the shell acts on, which browsers refuse to create. */
|
||||
const SHELL_NAMES = new Set(['desktop.ini', 'thumbs.db', 'autorun.inf']);
|
||||
const SHELL_EXTENSIONS = new Set(['.lnk', '.url', '.scf', '.local', '.pif', '.shb', '.cpl', '.inf']);
|
||||
/** A file system's limit is 255 bytes. Below it, leaving room for " (n)". */
|
||||
const MAX_NAME_BYTES = 240;
|
||||
|
||||
function truncateName(name: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(name).length <= MAX_NAME_BYTES) return name;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const ext = dot > 0 && encoder.encode(name.slice(dot)).length <= 32 ? name.slice(dot) : '';
|
||||
let room = MAX_NAME_BYTES - encoder.encode(ext).length;
|
||||
let stem = '';
|
||||
// By code point, so a character is never cut in half.
|
||||
for (const char of ext ? name.slice(0, dot) : name) {
|
||||
room -= encoder.encode(char).length;
|
||||
if (room < 0) break;
|
||||
stem += char;
|
||||
}
|
||||
return stem.trimEnd() + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name the folder will take: no path parts a sender could smuggle in, no
|
||||
* characters a file system refuses, and nothing Windows or the shell treats as
|
||||
* special. Renamed, so the attachment is still saved.
|
||||
*/
|
||||
function safeFileName(raw: string, fallback: string): string {
|
||||
const leaf = raw.split(/[\\/]/).pop() ?? '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim();
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return fallback;
|
||||
const lower = cleaned.toLowerCase();
|
||||
const stem = lower.split('.')[0];
|
||||
const dot = lower.lastIndexOf('.');
|
||||
let safe = cleaned;
|
||||
if (RESERVED_STEM.test(stem) || SHELL_NAMES.has(lower)) safe = `_${cleaned}`;
|
||||
else if (dot > 0 && SHELL_EXTENSIONS.has(lower.slice(dot))) safe = `${cleaned}.txt`;
|
||||
return truncateName(safe);
|
||||
}
|
||||
|
||||
/** The folder or the browser refused this name, which says nothing about the next file. */
|
||||
function isNameRefused(error: unknown): boolean {
|
||||
const kind = (error as { name?: unknown } | null)?.name;
|
||||
return kind === 'TypeError' || kind === 'SecurityError';
|
||||
}
|
||||
|
||||
async function existsInFolder(folder: FolderHandle, name: string): Promise<boolean> {
|
||||
try {
|
||||
await folder.getFileHandle(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const kind = (error as { name?: unknown } | null)?.name;
|
||||
if (kind === 'NotFoundError') return false;
|
||||
// A sub-folder of that name: taken as well.
|
||||
if (kind === 'TypeMismatchError') return true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first of the name's alternatives neither this batch nor the folder has. */
|
||||
async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet<string>): Promise<string> {
|
||||
let n = 0;
|
||||
while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++;
|
||||
return numberedName(base, n);
|
||||
}
|
||||
|
||||
export interface FolderSaveItem {
|
||||
name: string;
|
||||
/** The file's content, or null when it has none to save. Fetched only when its turn comes. */
|
||||
read: () => Promise<Blob | BufferSource | null>;
|
||||
}
|
||||
|
||||
export interface FolderSaveResult {
|
||||
folder: string;
|
||||
total: number;
|
||||
saved: number;
|
||||
/** Files that couldn't be read from the server, or whose name was refused; the rest still went. */
|
||||
skipped: number;
|
||||
/** Why the run ended early, when it did. */
|
||||
stopped: 'policy' | 'write' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the files one at a time. A file that can't be fetched or whose name is
|
||||
* refused is skipped, like in the zip. A write that fails stops the run, because
|
||||
* the next one would fail the same way (disk full, permission gone), and the
|
||||
* result says how many made it. A file already in the folder is never overwritten.
|
||||
*/
|
||||
export async function saveFilesToFolder(
|
||||
folder: FolderHandle,
|
||||
items: readonly FolderSaveItem[],
|
||||
allowed: () => boolean,
|
||||
): Promise<FolderSaveResult> {
|
||||
const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null };
|
||||
const claimed = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
if (!allowed()) {
|
||||
result.stopped = 'policy';
|
||||
return result;
|
||||
}
|
||||
|
||||
let data: Blob | BufferSource | null;
|
||||
try {
|
||||
data = await item.read();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (data === null) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// The policy may have changed during the fetch, and nothing is on disk yet.
|
||||
if (!allowed()) {
|
||||
result.stopped = 'policy';
|
||||
return result;
|
||||
}
|
||||
|
||||
let name: string | null = null;
|
||||
let writable: WritableFile | null = null;
|
||||
try {
|
||||
name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed);
|
||||
claimed.add(name);
|
||||
const file = await folder.getFileHandle(name, { create: true });
|
||||
writable = await file.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
result.saved += 1;
|
||||
} catch (error) {
|
||||
await writable?.abort?.().catch(() => {});
|
||||
// getFileHandle created an empty file; don't leave it looking saved.
|
||||
if (name) await folder.removeEntry?.(name).catch(() => {});
|
||||
if (isNameRefused(error)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
result.stopped = 'write';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* One token renewal at a time across the windows of this browser.
|
||||
*
|
||||
* Refresh tokens rotate, and the token route reads the current one from a
|
||||
* cookie. Two windows renewing at once send the same token twice; on Stalwart
|
||||
* 1.0 a superseded token reused past its retry window counts as a replay and
|
||||
* revokes the whole grant. Held across the request, so the next window's
|
||||
* request carries the cookie the first one's answer wrote.
|
||||
*
|
||||
* Without Web Locks, or after waiting too long for a window that hangs, the
|
||||
* work runs anyway: never worse than having no lock.
|
||||
*/
|
||||
|
||||
interface LockManagerLike {
|
||||
request<T>(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */
|
||||
export const TOKEN_LOCK_WAIT_MS = 30_000;
|
||||
|
||||
const NOT_HELD = Symbol('not held');
|
||||
|
||||
export interface TokenLockTurn {
|
||||
/**
|
||||
* Another window held the lock first, so it may have renewed already. A
|
||||
* renewal that was going to force one should take the cached token instead:
|
||||
* an identity provider can refuse a second one this soon (Rauthy `nbf`, #552).
|
||||
*/
|
||||
contended: boolean;
|
||||
}
|
||||
|
||||
function lockManager(): LockManagerLike | null {
|
||||
const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined;
|
||||
return locks && typeof locks.request === 'function' ? locks : null;
|
||||
}
|
||||
|
||||
export function tokenLockName(slot: number): string {
|
||||
return `bulwark-token-refresh:${slot}`;
|
||||
}
|
||||
|
||||
export async function withTokenLock<T>(
|
||||
slot: number,
|
||||
work: (turn: TokenLockTurn) => Promise<T>,
|
||||
options: { waitMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const locks = lockManager();
|
||||
if (!locks) return work({ contended: false });
|
||||
|
||||
const name = tokenLockName(slot);
|
||||
let started = false;
|
||||
const run = (contended: boolean) => {
|
||||
started = true;
|
||||
return work({ contended });
|
||||
};
|
||||
|
||||
const gaveUp = new AbortController();
|
||||
const forwardAbort = () => gaveUp.abort();
|
||||
options.signal?.addEventListener('abort', forwardAbort);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const first = await locks.request<T | typeof NOT_HELD>(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD));
|
||||
if (first !== NOT_HELD) return first as T;
|
||||
timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS);
|
||||
return await locks.request<T>(name, { signal: gaveUp.signal }, () => {
|
||||
clearTimeout(timer);
|
||||
return run(true);
|
||||
});
|
||||
} catch (error) {
|
||||
// An error from the work goes to the caller, who decides what it means. So
|
||||
// does a caller calling it off (a sign-out). Only the lock itself failing,
|
||||
// or the wait running out, runs the work.
|
||||
if (started || options.signal?.aborted) throw error;
|
||||
return run(true);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener('abort', forwardAbort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Runs in the inline boot script, before the first paint. The preload bridge
|
||||
* exists before any page script, so app/desktop.css holds from the first
|
||||
* frame. Nothing happens in a browser, which has no bridge.
|
||||
*/
|
||||
export function desktopBootScript(): string {
|
||||
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
|
||||
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
|
||||
*
|
||||
* Only what the webmail uses so far. The desktop shell fills it in.
|
||||
*/
|
||||
|
||||
export interface DesktopWindowsBridge {
|
||||
/**
|
||||
* Opens a pop-out window itself. A `name` that is already open focuses that
|
||||
* window and doesn't load `url` again: one window per message. Left out, the
|
||||
* page uses window.open with a features string, which the shell's
|
||||
* window-open handler sees too.
|
||||
*/
|
||||
open?: (url: string, options: { name: string; width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
windows?: DesktopWindowsBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
bulwarkDesktop?: BulwarkDesktopBridge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Keeps what the windows of the app share up to date without a reload: theme,
|
||||
* language and settings. Each is a persisted store, so a change in one window
|
||||
* reaches the others as a `storage` event. Read state and drafts go through
|
||||
* lib/popout/channel.ts, and each window keeps its own account (see
|
||||
* local-data/desktop-offline-decisions.md).
|
||||
*
|
||||
* Only in pop-outs and the desktop app: tabs of a browser keep today's
|
||||
* behaviour, where a change shows at the next load.
|
||||
*/
|
||||
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { hasDesktopWindows } from '@/lib/popout/support';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
|
||||
export function windowSyncWanted(): boolean {
|
||||
return isPopoutWindow() || hasDesktopWindows();
|
||||
}
|
||||
|
||||
/** Starts listening; returns the stop. Does nothing where windowSyncWanted says no. */
|
||||
export function startWindowSync(): () => void {
|
||||
if (!windowSyncWanted()) return () => {};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
switch (event.key) {
|
||||
case 'theme-storage':
|
||||
// Rehydrating sets the state; this puts it on the page.
|
||||
void Promise.resolve(useThemeStore.persist.rehydrate()).then(() => useThemeStore.getState().initializeTheme());
|
||||
break;
|
||||
case 'locale-storage':
|
||||
// IntlProvider switches the catalog when the store changes.
|
||||
void useLocaleStore.persist.rehydrate();
|
||||
break;
|
||||
case 'settings-storage':
|
||||
void useSettingsStore.persist.rehydrate();
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}
|
||||
@@ -1687,6 +1687,25 @@ export class JMAPClient implements IJMAPClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client on a credential another window of this browser holds (a pop-out,
|
||||
* lib/popout): the header as that window sends it, whatever kind it is.
|
||||
*/
|
||||
static withSharedAuth(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
authHeader: string,
|
||||
onTokenRefresh?: () => Promise<string | null>,
|
||||
): JMAPClient {
|
||||
const client = new JMAPClient(serverUrl, username, '');
|
||||
client.authHeader = authHeader;
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
client.authMode = 'bearer';
|
||||
client.onTokenRefresh = onTokenRefresh;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
updateAccessToken(token: string): void {
|
||||
this.authHeader = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
|
||||
|
||||
/** The app's pages kept for a start without the network. */
|
||||
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal'];
|
||||
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_'];
|
||||
|
||||
/**
|
||||
* The files of this tab's build. After a deploy the server lists the new
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* What the windows of this browser say to each other about pop-outs. A
|
||||
* BroadcastChannel, because a window opened by the desktop bridge has no
|
||||
* opener, and a pop-out outlives a reload of the window that opened it.
|
||||
*
|
||||
* Every same-origin page hears this channel, and that includes privileged
|
||||
* plugin frames (untrusted ones have an opaque origin and don't). So no
|
||||
* credential goes on it in the clear: session answers are sealed for the
|
||||
* window that asked (lib/popout/sealed.ts), and a refused credential is named
|
||||
* by its fingerprint. Draft text does go on it, as it does for the plugins that
|
||||
* see drafts anyway.
|
||||
*/
|
||||
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import type { MessagePriority } from '@/lib/message-priority';
|
||||
import type { ComposePopoutPayload } from './compose-payload';
|
||||
import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
|
||||
|
||||
export const POPOUT_CHANNEL = 'bulwark:popout-windows';
|
||||
|
||||
/** One connected login, as the window that holds it sees it. */
|
||||
export interface SharedAccountSession {
|
||||
accountId: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
authMode: 'basic' | 'token' | 'oauth';
|
||||
authHeader: string;
|
||||
/** The window started offline: it has no credential to share (#523). */
|
||||
offlineOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface SharedSessionAnswer {
|
||||
activeAccountId: string | null;
|
||||
sessions: SharedAccountSession[];
|
||||
}
|
||||
|
||||
/** The send's outcome, for the main window to say and to offer Undo. The pop-out has closed by then. */
|
||||
export interface PopoutSentNotice {
|
||||
accountId: string | null;
|
||||
scheduled?: boolean;
|
||||
queued?: boolean;
|
||||
filingError?: boolean;
|
||||
rejectedRecipients?: string;
|
||||
/** stores/email-store.ts PendingUndoSend. */
|
||||
pendingUndoSend?: {
|
||||
submissionId: string;
|
||||
emailId?: string;
|
||||
identityId?: string;
|
||||
sendAt: string;
|
||||
isSmime: boolean;
|
||||
localAccountId?: string;
|
||||
submissionAccountId?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft whose window is closing, ready to save. A request started while a
|
||||
* window closes doesn't survive, and saving takes several, so another window
|
||||
* does it. The arguments of JMAPClient.createDraft, as plain data.
|
||||
*/
|
||||
export interface RescuedDraft {
|
||||
/** The login to save through; absent, the window's active one. */
|
||||
localAccountId?: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
identityId?: string;
|
||||
fromEmail?: string;
|
||||
/** The version this one replaces. */
|
||||
draftId?: string;
|
||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
fromName?: string;
|
||||
htmlBody?: string;
|
||||
inReplyTo?: string[];
|
||||
references?: string[];
|
||||
options?: { priority?: MessagePriority; previousDraftAccountId?: string; classification?: string | null };
|
||||
/** The composer's state: where the text goes when the save fails. */
|
||||
state: ComposerDraftData;
|
||||
/** For a reload of that window to ask which draft id came of it. */
|
||||
rescueKey?: string;
|
||||
}
|
||||
|
||||
export type PopoutMessage =
|
||||
| { type: 'session-request'; requestId: string; publicKey: JsonWebKey; accountId?: string; rejectedFingerprint?: string }
|
||||
| { type: 'session-answer'; requestId: string; sealed: SealedAnswer }
|
||||
| { type: 'handoff-request'; handoffId: string }
|
||||
| { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null }
|
||||
| { type: 'handoff-rendered'; handoffId: string }
|
||||
| { type: 'sent'; requestId: string; notice: PopoutSentNotice }
|
||||
| { type: 'sent-ack'; requestId: string }
|
||||
| { type: 'mail-changed'; accountId: string | null }
|
||||
| { type: 'state-change' }
|
||||
| { type: 'draft-rescue'; draft: RescuedDraft }
|
||||
| { type: 'rescue-query'; rescueKey: string }
|
||||
| { type: 'rescue-answer'; rescueKey: string; draftId: string | null }
|
||||
| { type: 'main-ping' }
|
||||
| { type: 'main-pong'; windowId: string; accountIds: string[] }
|
||||
| { type: 'main-gone'; windowId: string }
|
||||
| { type: 'account-removed'; accountId: string };
|
||||
|
||||
export interface PopoutChannel {
|
||||
post(message: PopoutMessage): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Null without BroadcastChannel: pop-outs aren't offered then (lib/popout/support.ts). */
|
||||
export function openPopoutChannel(onMessage: (message: PopoutMessage) => void): PopoutChannel | null {
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
let channel: BroadcastChannel;
|
||||
try {
|
||||
channel = new BroadcastChannel(POPOUT_CHANNEL);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent) => {
|
||||
const message = event.data as PopoutMessage | null;
|
||||
if (message && typeof message.type === 'string') onMessage(message);
|
||||
};
|
||||
return {
|
||||
post(message) {
|
||||
try {
|
||||
channel.postMessage(message);
|
||||
} catch {
|
||||
// The channel is closed, or the page is going away.
|
||||
}
|
||||
},
|
||||
close() {
|
||||
channel.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export { newId as newPopoutId };
|
||||
|
||||
/** How long a pop-out waits for a main window before it goes on without one. */
|
||||
export const SESSION_ANSWER_MS = 3000;
|
||||
|
||||
/**
|
||||
* The session of a window that is signed in. With `accountId`, only a window
|
||||
* holding that login answers, so the first answer is one that can be used.
|
||||
* With `rejectedAuthHeader` the server just refused that credential: the main
|
||||
* window renews it first when it still holds the same one. Null when no main
|
||||
* window answers in time.
|
||||
*/
|
||||
export async function requestWindowSession(
|
||||
options: { accountId?: string; rejectedAuthHeader?: string } = {},
|
||||
timeoutMs: number = SESSION_ANSWER_MS,
|
||||
): Promise<SharedSessionAnswer | null> {
|
||||
if (!canSealSessions()) return null;
|
||||
const opener = await createSessionOpener();
|
||||
const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined;
|
||||
return new Promise((resolve) => {
|
||||
const requestId = newId();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const finish = (answer: SharedSessionAnswer | null) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
channel?.close();
|
||||
resolve(answer);
|
||||
};
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type !== 'session-answer' || message.requestId !== requestId) return;
|
||||
void opener.open<SharedSessionAnswer>(message.sealed).then(finish, () => finish(null));
|
||||
});
|
||||
if (!channel) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(null), timeoutMs);
|
||||
channel.post({
|
||||
type: 'session-request',
|
||||
requestId,
|
||||
publicKey: opener.publicKey,
|
||||
...(options.accountId ? { accountId: options.accountId } : {}),
|
||||
...(rejectedFingerprint ? { rejectedFingerprint } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Tells a main window what was sent. True when one took it over. */
|
||||
export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = newId();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type === 'sent-ack' && message.requestId === requestId) finish(true);
|
||||
});
|
||||
const finish = (taken: boolean) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
channel?.close();
|
||||
resolve(taken);
|
||||
};
|
||||
if (!channel) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
channel.post({ type: 'sent', requestId, notice });
|
||||
});
|
||||
}
|
||||
|
||||
/** Tells the main windows that this pop-out changed mail they may be showing. */
|
||||
export function announceMailChanged(accountId: string | null): void {
|
||||
const channel = openPopoutChannel(() => {});
|
||||
channel?.post({ type: 'mail-changed', accountId });
|
||||
channel?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft id a closing window's text was saved under, for the same window
|
||||
* after a reload, whose own copy still names the version that replaced. Null
|
||||
* when no window saved anything for it.
|
||||
*/
|
||||
export function askRescuedDraftId(rescueKey: string, timeoutMs = 1500): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type === 'rescue-answer' && message.rescueKey === rescueKey) finish(message.draftId);
|
||||
});
|
||||
const finish = (draftId: string | null) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
channel?.close();
|
||||
resolve(draftId);
|
||||
};
|
||||
if (!channel) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(null), timeoutMs);
|
||||
channel.post({ type: 'rescue-query', rescueKey });
|
||||
});
|
||||
}
|
||||
|
||||
/** How often a pop-out checks that a main window is there, and how long an answer counts. */
|
||||
const MAIN_PING_MS = 4000;
|
||||
const MAIN_ALIVE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Keeps track, synchronously, of which main windows are open and which logins
|
||||
* they hold: a window closing has no time to ask. Also hands a closing draft to
|
||||
* one that can save it.
|
||||
*/
|
||||
export function watchMainWindow(): { alive(accountId?: string): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } {
|
||||
const windows = new Map<string, { at: number; accountIds: string[] }>();
|
||||
const channel = openPopoutChannel((message) => {
|
||||
if (message.type === 'main-pong') windows.set(message.windowId, { at: Date.now(), accountIds: message.accountIds });
|
||||
if (message.type === 'main-gone') windows.delete(message.windowId);
|
||||
});
|
||||
channel?.post({ type: 'main-ping' });
|
||||
const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS);
|
||||
const alive = (accountId?: string) => [...windows.values()].some((entry) =>
|
||||
Date.now() - entry.at < MAIN_ALIVE_MS && (!accountId || entry.accountIds.includes(accountId)));
|
||||
return {
|
||||
alive,
|
||||
rescue(draft) {
|
||||
if (!channel || !alive(draft.localAccountId)) return false;
|
||||
channel.post({ type: 'draft-rescue', draft });
|
||||
return true;
|
||||
},
|
||||
stop() {
|
||||
clearInterval(timer);
|
||||
channel?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Moves a composer into a pop-out. The window has to open inside the click,
|
||||
* but the draft is only ready once it is saved, so the opening window keeps
|
||||
* a pending handoff that the pop-out asks for by id and waits on. The opening
|
||||
* window lets go of its copy only once the pop-out says the composer is on
|
||||
* screen, with the text in it.
|
||||
*/
|
||||
|
||||
import { openPopoutChannel, newPopoutId, type PopoutChannel } from './channel';
|
||||
import type { ComposePopoutPayload } from './compose-payload';
|
||||
|
||||
/** A handoff nobody asked for after this long is dropped. Above the time a save can take. */
|
||||
const PENDING_TTL_MS = 120_000;
|
||||
/** How long a pop-out waits for its draft. */
|
||||
export const HANDOFF_WAIT_MS = 60_000;
|
||||
/** How often a pop-out asks again: the request may cross a reload of the window that opened it. */
|
||||
const HANDOFF_RETRY_MS = 1000;
|
||||
/** How long the opening window waits for the composer to be on screen before it keeps the draft. */
|
||||
export const HANDOFF_RENDER_WAIT_MS = 30_000;
|
||||
|
||||
const STORAGE_PREFIX = 'bulwark-popout-compose:';
|
||||
|
||||
interface Pending {
|
||||
requested: boolean;
|
||||
/** Undefined until the opening window decided; null means it won't come. */
|
||||
payload?: ComposePopoutPayload | null;
|
||||
rendered?: (shown: boolean) => void;
|
||||
renderTimer?: ReturnType<typeof setTimeout>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
let channel: PopoutChannel | null = null;
|
||||
|
||||
function deliver(handoffId: string, entry: Pending): void {
|
||||
if (!entry.requested || entry.payload === undefined) return;
|
||||
channel?.post({ type: 'handoff-answer', handoffId, payload: entry.payload });
|
||||
// Called off: nothing more to hear. Otherwise stay for "it is on screen".
|
||||
if (entry.payload === null) forget(handoffId);
|
||||
}
|
||||
|
||||
function forget(handoffId: string): void {
|
||||
const entry = pending.get(handoffId);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
if (entry.renderTimer) clearTimeout(entry.renderTimer);
|
||||
entry.rendered?.(false);
|
||||
}
|
||||
pending.delete(handoffId);
|
||||
if (pending.size === 0) {
|
||||
channel?.close();
|
||||
channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComposeHandoff {
|
||||
id: string;
|
||||
/** Hands the draft over. True once the composer is on screen in the other window; false if that doesn't happen in time. */
|
||||
complete(payload: ComposePopoutPayload, waitMs?: number): Promise<boolean>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/** Null without BroadcastChannel. */
|
||||
export function createComposeHandoff(): ComposeHandoff | null {
|
||||
channel ??= openPopoutChannel((message) => {
|
||||
if (message.type === 'handoff-request') {
|
||||
const entry = pending.get(message.handoffId);
|
||||
if (!entry) return;
|
||||
entry.requested = true;
|
||||
deliver(message.handoffId, entry);
|
||||
} else if (message.type === 'handoff-rendered') {
|
||||
const entry = pending.get(message.handoffId);
|
||||
if (!entry) return;
|
||||
entry.rendered?.(true);
|
||||
entry.rendered = undefined;
|
||||
forget(message.handoffId);
|
||||
}
|
||||
});
|
||||
if (!channel) return null;
|
||||
|
||||
const id = newPopoutId();
|
||||
pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) });
|
||||
return {
|
||||
id,
|
||||
complete(payload, waitMs = HANDOFF_RENDER_WAIT_MS) {
|
||||
const entry = pending.get(id);
|
||||
if (!entry) return Promise.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
entry.rendered = resolve;
|
||||
entry.renderTimer = setTimeout(() => {
|
||||
entry.rendered = undefined;
|
||||
resolve(false);
|
||||
}, waitMs);
|
||||
entry.payload = payload;
|
||||
deliver(id, entry);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
const entry = pending.get(id);
|
||||
// Also when the draft was delivered already: the pop-out is still listening.
|
||||
if (!entry) return;
|
||||
entry.payload = null;
|
||||
entry.requested = true;
|
||||
deliver(id, entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps the draft with this window, so a reload starts from it. */
|
||||
export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Full or unavailable: a reload then starts from the saved draft's first state.
|
||||
}
|
||||
}
|
||||
|
||||
/** The draft this window holds from before a reload. */
|
||||
export function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_PREFIX + handoffId);
|
||||
return raw ? (JSON.parse(raw) as ComposePopoutPayload) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pop-out's side. Null when the opening window called it off, or nothing
|
||||
* came in time.
|
||||
*/
|
||||
export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HANDOFF_WAIT_MS): Promise<ComposePopoutPayload | null> {
|
||||
const remembered = rememberedComposePayload(handoffId);
|
||||
if (remembered) return Promise.resolve(remembered);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let retry: ReturnType<typeof setInterval> | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const finish = (payload: ComposePopoutPayload | null) => {
|
||||
if (retry) clearInterval(retry);
|
||||
if (timer) clearTimeout(timer);
|
||||
listener?.close();
|
||||
resolve(payload);
|
||||
};
|
||||
const listener = openPopoutChannel((message) => {
|
||||
if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return;
|
||||
if (message.payload) rememberComposePayload(handoffId, message.payload);
|
||||
finish(message.payload);
|
||||
});
|
||||
if (!listener) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const ask = () => listener.post({ type: 'handoff-request', handoffId });
|
||||
timer = setTimeout(() => finish(null), timeoutMs);
|
||||
retry = setInterval(ask, HANDOFF_RETRY_MS);
|
||||
ask();
|
||||
});
|
||||
}
|
||||
|
||||
/** The composer with the text is on screen here: the opening window can let go. */
|
||||
export function acknowledgeComposeHandoff(handoffId: string): void {
|
||||
const sender = openPopoutChannel(() => {});
|
||||
sender?.post({ type: 'handoff-rendered', handoffId });
|
||||
sender?.close();
|
||||
}
|
||||
|
||||
/** Calls `onCancel` if the opening window takes the draft back. Returns the stop. */
|
||||
export function watchHandoffCancel(handoffId: string, onCancel: () => void): () => void {
|
||||
const listener = openPopoutChannel((message) => {
|
||||
if (message.type === 'handoff-answer' && message.handoffId === handoffId && message.payload === null) onCancel();
|
||||
});
|
||||
return () => listener?.close();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* What a composer needs to start in another window. Plain data: it crosses a
|
||||
* BroadcastChannel and is kept in the pop-out's sessionStorage over a reload.
|
||||
* Attachments are blob references, already on the server.
|
||||
*/
|
||||
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import type { ComposePrefill } from '@/lib/plugin-compose-gate';
|
||||
import type { EmailWriteSource } from '@/stores/email-store';
|
||||
import type { ProComposerMode, ProReplyContext } from '@/stores/pro-tab-store';
|
||||
|
||||
export interface ComposePopoutPayload {
|
||||
mode: ProComposerMode;
|
||||
replyTo?: ProReplyContext;
|
||||
initialDraftText?: string;
|
||||
initialData?: ComposerDraftData | null;
|
||||
prefill?: ComposePrefill | null;
|
||||
/** The message replied to or forwarded, for its $answered / $forwarded flag. */
|
||||
sourceEmailId?: string | null;
|
||||
sourceEmailSource?: EmailWriteSource | null;
|
||||
/** The mailbox a new message was started from; picks the identity (see the composer's prop). */
|
||||
composeFromAccountEmail?: string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Opens pop-out windows: one message, or a composer. Shared with the desktop
|
||||
* app's quick compose, which only has to call openComposerWindow.
|
||||
*/
|
||||
|
||||
import { appPath } from '@/lib/deep-links';
|
||||
import '@/lib/desktop/types';
|
||||
import { createComposeHandoff, type ComposeHandoff } from './compose-handoff';
|
||||
import type { ComposePopoutPayload } from './compose-payload';
|
||||
|
||||
const SIZES = {
|
||||
message: { width: 960, height: 820 },
|
||||
compose: { width: 760, height: 780 },
|
||||
} as const;
|
||||
|
||||
type PopoutKind = keyof typeof SIZES;
|
||||
|
||||
/** Offset from the opening window, so the new one doesn't hide it exactly. */
|
||||
const CASCADE_PX = 48;
|
||||
|
||||
export function popoutFeatures(kind: PopoutKind): string {
|
||||
const { width, height } = SIZES[kind];
|
||||
const left = Math.round((window.screenX || 0) + CASCADE_PX);
|
||||
const top = Math.round((window.screenY || 0) + CASCADE_PX);
|
||||
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
|
||||
}
|
||||
|
||||
/** Windows this page opened. window.open with a name would load the address in it again. */
|
||||
const openedWindows = new Map<string, Window>();
|
||||
|
||||
/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */
|
||||
function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean {
|
||||
const bridge = window.bulwarkDesktop?.windows;
|
||||
if (bridge?.open) {
|
||||
bridge.open(url, { name, ...SIZES[kind] });
|
||||
return true;
|
||||
}
|
||||
const existing = openedWindows.get(name);
|
||||
if (existing && !existing.closed) return existing;
|
||||
// Not noopener: in an installed app that is what sends a window to the
|
||||
// system browser (lib/standalone-display.ts).
|
||||
const opened = window.open(url, name, popoutFeatures(kind));
|
||||
if (!opened) return false;
|
||||
openedWindows.set(name, opened);
|
||||
return opened;
|
||||
}
|
||||
|
||||
function focused(opened: Window | boolean): boolean {
|
||||
if (opened && typeof opened !== 'boolean') opened.focus?.();
|
||||
return !!opened;
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
|
||||
const text = search.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
export interface MessageWindowTarget {
|
||||
emailId: string;
|
||||
/** The login the message is read through. */
|
||||
localAccountId?: string | null;
|
||||
/** The JMAP account holding it, for a shared or group folder. */
|
||||
ownerAccountId?: string | null;
|
||||
}
|
||||
|
||||
export function messagePopoutUrl(target: MessageWindowTarget): string {
|
||||
return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`)
|
||||
+ query({ account: target.localAccountId, owner: target.ownerAccountId });
|
||||
}
|
||||
|
||||
/** The same message twice focuses the same window, without loading it again. */
|
||||
export function openMessageWindow(target: MessageWindowTarget): boolean {
|
||||
return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
|
||||
}
|
||||
|
||||
export interface ComposerWindow {
|
||||
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
|
||||
handOver(payload: ComposePopoutPayload): Promise<boolean>;
|
||||
/** Calls it off: the pop-out closes. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an empty pop-out composer that waits for its draft. Call it straight
|
||||
* from the click: a browser only opens a window for a user action. Null when
|
||||
* no window opened.
|
||||
*/
|
||||
export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null {
|
||||
const handoff: ComposeHandoff | null = createComposeHandoff();
|
||||
if (!handoff) return null;
|
||||
const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId });
|
||||
const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose');
|
||||
if (!focused(opened)) {
|
||||
handoff.cancel();
|
||||
return null;
|
||||
}
|
||||
return { handOver: handoff.complete, cancel: handoff.cancel };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Text from a closing pop-out that couldn't be saved. It waits here, kept over
|
||||
* a reload, until the mail view can put it in its "Continue draft" slot.
|
||||
*/
|
||||
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
|
||||
const STORAGE_KEY = 'bulwark-rescued-drafts';
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): ComposerDraftData[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
const list: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(list) ? (list as ComposerDraftData[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(list: ComposerDraftData[]): void {
|
||||
try {
|
||||
if (list.length === 0) sessionStorage.removeItem(STORAGE_KEY);
|
||||
else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// Full or unavailable: it stays for as long as this page does, below.
|
||||
}
|
||||
}
|
||||
|
||||
let memory: ComposerDraftData[] | null = null;
|
||||
|
||||
export function keepRescuedDraft(draft: ComposerDraftData): void {
|
||||
memory = [...(memory ?? read()), { ...draft, unsaved: true }];
|
||||
write(memory);
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
/** The oldest waiting draft, taken off the list; null when there is none. */
|
||||
export function takeRescuedDraft(): ComposerDraftData | null {
|
||||
const list = memory ?? read();
|
||||
if (list.length === 0) return null;
|
||||
const [first, ...rest] = list;
|
||||
memory = rest;
|
||||
write(rest);
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Calls `listener` when a draft comes in. Returns the unsubscribe. */
|
||||
export function onRescuedDraft(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Credentials between windows, sealed for the window that asked. The request
|
||||
* carries a throwaway public key, and the answer can only be opened with the
|
||||
* private key, which is not extractable and never leaves that window. So a
|
||||
* listener on the channel, such as a privileged plugin frame, hears no
|
||||
* credential go by.
|
||||
*
|
||||
* A BroadcastChannel has no way to hand over a MessagePort, and a window the
|
||||
* desktop bridge opens has no opener to send one through.
|
||||
*/
|
||||
|
||||
export function canSealSessions(): boolean {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle;
|
||||
}
|
||||
|
||||
const ECDH = { name: 'ECDH', namedCurve: 'P-256' } as const;
|
||||
const AES = { name: 'AES-GCM', length: 256 } as const;
|
||||
|
||||
function toBase64(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let binary = '';
|
||||
for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export interface SealedAnswer {
|
||||
publicKey: JsonWebKey;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface SessionOpener {
|
||||
publicKey: JsonWebKey;
|
||||
open<T>(sealed: SealedAnswer): Promise<T>;
|
||||
}
|
||||
|
||||
async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise<CryptoKey> {
|
||||
const publicKey = await crypto.subtle.importKey('jwk', peer, ECDH, false, []);
|
||||
return crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, AES, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** The asking window's side: a key to send along, and what opens the answer. */
|
||||
export async function createSessionOpener(): Promise<SessionOpener> {
|
||||
const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
|
||||
const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
return {
|
||||
publicKey,
|
||||
async open<T>(sealed: SealedAnswer): Promise<T> {
|
||||
const key = await deriveKey(pair.privateKey, sealed.publicKey);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromBase64(sealed.iv) }, key, fromBase64(sealed.data));
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The answering window's side. */
|
||||
export async function sealFor(requester: JsonWebKey, value: unknown): Promise<SealedAnswer> {
|
||||
const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
|
||||
const key = await deriveKey(pair.privateKey, requester);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value)));
|
||||
return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), iv: toBase64(iv), data: toBase64(data) };
|
||||
}
|
||||
|
||||
/** Says which credential the server refused without saying what it is. */
|
||||
export async function credentialFingerprint(header: string): Promise<string> {
|
||||
return toBase64(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(header)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Whether "Open in new window" is on offer: an installed app or the desktop
|
||||
* app, on a device with windows. In a browser tab, a tab does the same job
|
||||
* and the app behaves as it always has.
|
||||
*/
|
||||
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
|
||||
import '@/lib/desktop/types';
|
||||
import { canSealSessions } from './sealed';
|
||||
|
||||
export function hasDesktopWindows(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows;
|
||||
}
|
||||
|
||||
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
|
||||
export function popoutsAvailable(): boolean {
|
||||
// Lite has no such route: a static export can't list every message id.
|
||||
if (IS_LITE || typeof window === 'undefined') return false;
|
||||
// Sharing the session and renewing tokens safely across windows need these.
|
||||
if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false;
|
||||
if (hasDesktopWindows()) return true;
|
||||
if (!isStandaloneDisplayMode()) return false;
|
||||
// A home-screen app on a phone or tablet has no second window: window.open
|
||||
// leaves for the browser there.
|
||||
if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false;
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Whether this window is a pop-out (`/[locale]/popout/...`): a single message
|
||||
* or a composer in its own window, without the rail or the sidebar.
|
||||
*
|
||||
* A pop-out is a guest of the main window's session. It never evicts an
|
||||
* account, never starts a token timer and doesn't write the account list:
|
||||
* stores/auth-store.ts and stores/account-store.ts ask here.
|
||||
*/
|
||||
|
||||
import { withBasePath } from '@/lib/browser-navigation';
|
||||
import { locales } from '@/i18n/routing';
|
||||
|
||||
const BUILD_BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
|
||||
|
||||
/** Path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */
|
||||
const OTHER_SECTIONS = new Set(['mail', 'calendar', 'contacts', 'files', 'settings', 'apps', 'pro', 'admin', 'protocol', 'setup', 'connector', 'login', 'auth', 'plugins']);
|
||||
|
||||
export function isPopoutPath(pathname: string): boolean {
|
||||
// The mount comes off first: an app served under /mail isn't on its mail page.
|
||||
const inApp = BUILD_BASE_PATH && (pathname === BUILD_BASE_PATH || pathname.startsWith(`${BUILD_BASE_PATH}/`))
|
||||
? pathname.slice(BUILD_BASE_PATH.length)
|
||||
: pathname;
|
||||
const segments = inApp.split('/').filter(Boolean);
|
||||
const at = segments.indexOf('popout');
|
||||
if (at < 0) return false;
|
||||
// Before it: nothing, or a locale, with a mount only a reverse proxy knows ahead of that.
|
||||
const before = segments.slice(0, at);
|
||||
if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false;
|
||||
if (before.some((segment) => OTHER_SECTIONS.has(segment))) return false;
|
||||
const rest = segments.slice(at + 1);
|
||||
return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message');
|
||||
}
|
||||
|
||||
export function isPopoutWindow(): boolean {
|
||||
return typeof window !== 'undefined' && isPopoutPath(window.location.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage for the stores a pop-out shares with the main window, without
|
||||
* its writes: a pop-out on account B would otherwise switch the main window
|
||||
* to B at its next reload, and write back a list older than the main
|
||||
* window's. Throws on the server like a bare `localStorage` does, which
|
||||
* createJSONStorage takes as "no storage".
|
||||
*/
|
||||
export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
||||
if (typeof localStorage === 'undefined') throw new Error('No localStorage here');
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(key),
|
||||
setItem: (key, value) => {
|
||||
if (!isPopoutWindow()) localStorage.setItem(key, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
if (!isPopoutWindow()) localStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the pop-out. A script may only close a window a script opened, so a
|
||||
* pop-out address opened by hand goes to the app instead.
|
||||
*/
|
||||
export function closePopoutWindow(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.close();
|
||||
window.setTimeout(() => {
|
||||
if (!window.closed) window.location.replace(withBasePath('/'));
|
||||
}, 300);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
|
||||
import type { ParsedMailto } from "./mailto";
|
||||
import type { ParsedWebcal } from "./webcal";
|
||||
import { matchSurface } from "@/lib/deep-links";
|
||||
@@ -136,9 +137,7 @@ function getMailtoClientPriority(info: ProtocolClientInfo): number {
|
||||
}
|
||||
|
||||
function getDefaultProtocolClientInfo(): ProtocolClientInfo {
|
||||
const nav = navigator as Navigator & { standalone?: boolean };
|
||||
const standalone = window.matchMedia?.("(display-mode: standalone)").matches || nav.standalone === true;
|
||||
return { path: window.location.pathname, standalone, clientId: BROWSER_CLIENT_ID };
|
||||
return { path: window.location.pathname, standalone: isStandaloneDisplayMode(), clientId: BROWSER_CLIENT_ID };
|
||||
}
|
||||
|
||||
async function requestMailtoViaServiceWorker(value: ParsedMailto, timeoutMs: number): Promise<boolean> {
|
||||
|
||||
@@ -38,6 +38,7 @@ const SIGNED_OUT_STORAGE_KEYS = [
|
||||
];
|
||||
|
||||
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
|
||||
export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY };
|
||||
|
||||
// Stores the auth store can't import (they import it) clear themselves here.
|
||||
const signedOutCleanups = new Set<() => void>();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Window Controls Overlay: an installed app (and the desktop app's
|
||||
* titleBarOverlay window) draws the page up to the top of the window and lays
|
||||
* the native minimise, maximise and close buttons over it.
|
||||
*
|
||||
* Asks navigator.windowControlsOverlay, never the media query:
|
||||
* `(display-mode: window-controls-overlay)` is false in Electron with
|
||||
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
|
||||
*
|
||||
* app/globals.css gives --titlebar-inset its first value from
|
||||
* env(titlebar-area-height, 0px), so the page has the right top padding from
|
||||
* the first paint, before any script runs. This module keeps the variable in
|
||||
* step with the API afterwards, which is what a browser or bridge without the
|
||||
* env() values relies on.
|
||||
*/
|
||||
|
||||
export interface TitlebarArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface OverlayLike extends EventTarget {
|
||||
visible: boolean;
|
||||
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export const TITLEBAR_INSET_VAR = '--titlebar-inset';
|
||||
|
||||
function overlay(): OverlayLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay;
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */
|
||||
export function readTitlebarArea(): TitlebarArea | null {
|
||||
const current = overlay();
|
||||
if (!current?.visible) return null;
|
||||
const rect = current.getTitlebarAreaRect?.();
|
||||
if (!rect || !(rect.height > 0)) return null;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets --titlebar-inset for this area. With no area the inline value goes, so
|
||||
* the stylesheet's value (0 without an overlay) is what counts again.
|
||||
*/
|
||||
export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
||||
if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
|
||||
else root.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `listener` now and whenever the overlay's size or visibility changes
|
||||
* (`geometrychange`). Without the API it calls it once with null and listens
|
||||
* to nothing, so a browser without it runs no extra code. Returns the
|
||||
* unsubscribe.
|
||||
*/
|
||||
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
|
||||
listener(readTitlebarArea());
|
||||
const current = overlay();
|
||||
if (!current) return () => {};
|
||||
const onChange = () => listener(readTitlebarArea());
|
||||
current.addEventListener('geometrychange', onChange);
|
||||
return () => current.removeEventListener('geometrychange', onChange);
|
||||
}
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "الخطأ الإملائي أو النحوي التالي",
|
||||
"previous_issue": "الخطأ الإملائي أو النحوي السابق"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "فتح في نافذة جديدة",
|
||||
"save_all_to_folder": "حفظ الكل في مجلد…",
|
||||
"saved_all": "{count, plural, zero {لم يُحفظ أي مرفق في {folder}.} one {تم حفظ مرفق واحد في {folder}.} two {تم حفظ مرفقين في {folder}.} few {تم حفظ # مرفقات في {folder}.} many {تم حفظ # مرفقًا في {folder}.} other {تم حفظ # مرفق في {folder}.}}",
|
||||
"saved_partial": "تم حفظ {saved} من أصل {total} مرفقات في {folder}.",
|
||||
"save_failed": "تعذر حفظ المرفقات في {folder}.",
|
||||
"popup_blocked": "حظر المتصفح النافذة الجديدة. اسمح بالنوافذ المنبثقة لهذا الموقع وحاول مرة أخرى.",
|
||||
"opening": "جارٍ الفتح…",
|
||||
"session_ended_title": "تم تسجيل خروجك في هذه النافذة",
|
||||
"session_ended_body": "افتح التطبيق وسجّل الدخول، ثم افتح هذه النافذة مرة أخرى.",
|
||||
"open_app": "فتح التطبيق",
|
||||
"close_window": "إغلاق النافذة",
|
||||
"message_gone_title": "لم تعد هذه الرسالة موجودة",
|
||||
"message_gone_body": "تم حذفها أو نقلها إلى حساب آخر.",
|
||||
"message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.",
|
||||
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
|
||||
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Error d'ortografia o de gramàtica següent",
|
||||
"previous_issue": "Error d'ortografia o de gramàtica anterior"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Obre en una finestra nova",
|
||||
"save_all_to_folder": "Desa-ho tot en una carpeta…",
|
||||
"saved_all": "{count, plural, one {S’ha desat # adjunt a {folder}.} other {S’han desat # adjunts a {folder}.}}",
|
||||
"saved_partial": "S’han desat {saved} de {total} adjunts a {folder}.",
|
||||
"save_failed": "No s’han pogut desar els adjunts a {folder}.",
|
||||
"popup_blocked": "El navegador ha blocat la finestra nova. Permet les finestres emergents per a aquest lloc i torna-ho a provar.",
|
||||
"opening": "S’està obrint…",
|
||||
"session_ended_title": "Has tancat la sessió en aquesta finestra",
|
||||
"session_ended_body": "Obre l’aplicació i inicia la sessió, i després torna a obrir aquesta finestra.",
|
||||
"open_app": "Obre l’aplicació",
|
||||
"close_window": "Tanca la finestra",
|
||||
"message_gone_title": "Aquest missatge ja no hi és",
|
||||
"message_gone_body": "S’ha suprimit o s’ha mogut a un altre compte.",
|
||||
"message_moved": "Aquest missatge s’ha mogut a una altra carpeta.",
|
||||
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
|
||||
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Další pravopisná nebo gramatická chyba",
|
||||
"previous_issue": "Předchozí pravopisná nebo gramatická chyba"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Otevřít v novém okně",
|
||||
"save_all_to_folder": "Uložit vše do složky…",
|
||||
"saved_all": "{count, plural, one {# příloha uložena do {folder}.} few {# přílohy uloženy do {folder}.} other {# příloh uloženo do {folder}.}}",
|
||||
"saved_partial": "Uloženo {saved} z {total} příloh do {folder}.",
|
||||
"save_failed": "Přílohy se nepodařilo uložit do {folder}.",
|
||||
"popup_blocked": "Prohlížeč nové okno zablokoval. Povolte vyskakovací okna pro tento web a zkuste to znovu.",
|
||||
"opening": "Otevírání…",
|
||||
"session_ended_title": "V tomto okně jste odhlášeni",
|
||||
"session_ended_body": "Otevřete aplikaci, přihlaste se a potom toto okno otevřete znovu.",
|
||||
"open_app": "Otevřít aplikaci",
|
||||
"close_window": "Zavřít okno",
|
||||
"message_gone_title": "Tato zpráva už tu není",
|
||||
"message_gone_body": "Byla smazána nebo přesunuta do jiného účtu.",
|
||||
"message_moved": "Tato zpráva byla přesunuta do jiné složky.",
|
||||
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
|
||||
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Næste stave- eller grammatikfejl",
|
||||
"previous_issue": "Forrige stave- eller grammatikfejl"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Åbn i nyt vindue",
|
||||
"save_all_to_folder": "Gem alle i en mappe…",
|
||||
"saved_all": "{count, plural, one {# vedhæftet fil gemt i {folder}.} other {# vedhæftede filer gemt i {folder}.}}",
|
||||
"saved_partial": "{saved} af {total} vedhæftede filer gemt i {folder}.",
|
||||
"save_failed": "De vedhæftede filer kunne ikke gemmes i {folder}.",
|
||||
"popup_blocked": "Browseren blokerede det nye vindue. Tillad pop op-vinduer for dette websted, og prøv igen.",
|
||||
"opening": "Åbner…",
|
||||
"session_ended_title": "Du er logget ud i dette vindue",
|
||||
"session_ended_body": "Åbn appen og log ind, og åbn derefter dette vindue igen.",
|
||||
"open_app": "Åbn appen",
|
||||
"close_window": "Luk vindue",
|
||||
"message_gone_title": "Denne besked er væk",
|
||||
"message_gone_body": "Den er slettet eller flyttet til en anden konto.",
|
||||
"message_moved": "Denne besked er flyttet til en anden mappe.",
|
||||
"message_moved_to": "Denne besked er flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Nächster Rechtschreib- oder Grammatikfehler",
|
||||
"previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "In neuem Fenster öffnen",
|
||||
"save_all_to_folder": "Alle in einem Ordner speichern…",
|
||||
"saved_all": "{count, plural, one {# Anhang in {folder} gespeichert.} other {# Anhänge in {folder} gespeichert.}}",
|
||||
"saved_partial": "{saved} von {total} Anhängen in {folder} gespeichert.",
|
||||
"save_failed": "Die Anhänge konnten nicht in {folder} gespeichert werden.",
|
||||
"popup_blocked": "Der Browser hat das neue Fenster blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es noch einmal.",
|
||||
"opening": "Wird geöffnet…",
|
||||
"session_ended_title": "Sie sind in diesem Fenster abgemeldet",
|
||||
"session_ended_body": "Öffnen Sie die App, melden Sie sich an und öffnen Sie dieses Fenster dann noch einmal.",
|
||||
"open_app": "App öffnen",
|
||||
"close_window": "Fenster schließen",
|
||||
"message_gone_title": "Diese Nachricht ist nicht mehr da",
|
||||
"message_gone_body": "Sie wurde gelöscht oder in ein anderes Konto verschoben.",
|
||||
"message_moved": "Diese Nachricht wurde in einen anderen Ordner verschoben.",
|
||||
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
|
||||
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Next spelling or grammar issue",
|
||||
"previous_issue": "Previous spelling or grammar issue"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Open in new window",
|
||||
"save_all_to_folder": "Save all to a folder…",
|
||||
"saved_all": "{count, plural, one {Saved # attachment to {folder}.} other {Saved # attachments to {folder}.}}",
|
||||
"saved_partial": "Saved {saved} of {total} attachments to {folder}.",
|
||||
"save_failed": "Could not save the attachments to {folder}.",
|
||||
"popup_blocked": "The browser blocked the new window. Allow pop-ups for this site and try again.",
|
||||
"opening": "Opening…",
|
||||
"session_ended_title": "You are signed out in this window",
|
||||
"session_ended_body": "Open the app and sign in, then open this window again.",
|
||||
"open_app": "Open the app",
|
||||
"close_window": "Close window",
|
||||
"message_gone_title": "This message is gone",
|
||||
"message_gone_body": "It was deleted or moved to another account.",
|
||||
"message_moved": "This message was moved to another folder.",
|
||||
"message_moved_to": "This message was moved to {folder}.",
|
||||
"window_not_ready": "The new window did not open in time. Your draft stays here."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Siguiente error de ortografía o gramática",
|
||||
"previous_issue": "Anterior error de ortografía o gramática"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Abrir en una ventana nueva",
|
||||
"save_all_to_folder": "Guardar todo en una carpeta…",
|
||||
"saved_all": "{count, plural, one {Se guardó # adjunto en {folder}.} other {Se guardaron # adjuntos en {folder}.}}",
|
||||
"saved_partial": "Se guardaron {saved} de {total} adjuntos en {folder}.",
|
||||
"save_failed": "No se pudieron guardar los adjuntos en {folder}.",
|
||||
"popup_blocked": "El navegador bloqueó la ventana nueva. Permite las ventanas emergentes para este sitio e inténtalo de nuevo.",
|
||||
"opening": "Abriendo…",
|
||||
"session_ended_title": "Has cerrado sesión en esta ventana",
|
||||
"session_ended_body": "Abre la aplicación e inicia sesión, y luego vuelve a abrir esta ventana.",
|
||||
"open_app": "Abrir la aplicación",
|
||||
"close_window": "Cerrar ventana",
|
||||
"message_gone_title": "Este mensaje ya no está",
|
||||
"message_gone_body": "Se eliminó o se movió a otra cuenta.",
|
||||
"message_moved": "Este mensaje se movió a otra carpeta.",
|
||||
"message_moved_to": "Este mensaje se movió a {folder}.",
|
||||
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Järgmine õigekirja- või grammatikaviga",
|
||||
"previous_issue": "Eelmine õigekirja- või grammatikaviga"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Ava uues aknas",
|
||||
"save_all_to_folder": "Salvesta kõik kausta…",
|
||||
"saved_all": "{count, plural, one {# manus salvestati kausta {folder}.} other {# manust salvestati kausta {folder}.}}",
|
||||
"saved_partial": "{total} manusest {saved} salvestati kausta {folder}.",
|
||||
"save_failed": "Manuseid ei saanud kausta {folder} salvestada.",
|
||||
"popup_blocked": "Brauser blokeeris uue akna. Luba selle saidi hüpikaknad ja proovi uuesti.",
|
||||
"opening": "Avamine…",
|
||||
"session_ended_title": "Oled selles aknas välja logitud",
|
||||
"session_ended_body": "Ava rakendus ja logi sisse, seejärel ava see aken uuesti.",
|
||||
"open_app": "Ava rakendus",
|
||||
"close_window": "Sulge aken",
|
||||
"message_gone_title": "Seda kirja enam ei ole",
|
||||
"message_gone_body": "See kustutati või teisaldati teisele kontole.",
|
||||
"message_moved": "See kiri teisaldati teise kausta.",
|
||||
"message_moved_to": "See kiri teisaldati kausta {folder}.",
|
||||
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "خطای املایی یا دستوری بعدی",
|
||||
"previous_issue": "خطای املایی یا دستوری قبلی"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "باز کردن در پنجرهٔ جدید",
|
||||
"save_all_to_folder": "ذخیرهٔ همه در یک پوشه…",
|
||||
"saved_all": "{count, plural, one {# پیوست در {folder} ذخیره شد.} other {# پیوست در {folder} ذخیره شد.}}",
|
||||
"saved_partial": "{saved} از {total} پیوست در {folder} ذخیره شد.",
|
||||
"save_failed": "پیوستها در {folder} ذخیره نشدند.",
|
||||
"popup_blocked": "مرورگر پنجرهٔ جدید را مسدود کرد. پنجرههای بازشو را برای این سایت مجاز کنید و دوباره امتحان کنید.",
|
||||
"opening": "در حال باز کردن…",
|
||||
"session_ended_title": "در این پنجره از حساب خارج شدهاید",
|
||||
"session_ended_body": "برنامه را باز کنید و وارد شوید، سپس این پنجره را دوباره باز کنید.",
|
||||
"open_app": "باز کردن برنامه",
|
||||
"close_window": "بستن پنجره",
|
||||
"message_gone_title": "این پیام دیگر وجود ندارد",
|
||||
"message_gone_body": "حذف شده یا به حساب دیگری منتقل شده است.",
|
||||
"message_moved": "این پیام به پوشهٔ دیگری منتقل شد.",
|
||||
"message_moved_to": "این پیام به {folder} منتقل شد.",
|
||||
"window_not_ready": "پنجرهٔ جدید بهموقع باز نشد. پیشنویس شما همینجا میماند."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Faute d'orthographe ou de grammaire suivante",
|
||||
"previous_issue": "Faute d'orthographe ou de grammaire précédente"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"save_all_to_folder": "Tout enregistrer dans un dossier…",
|
||||
"saved_all": "{count, plural, one {# pièce jointe enregistrée dans {folder}.} other {# pièces jointes enregistrées dans {folder}.}}",
|
||||
"saved_partial": "{saved} pièces jointes sur {total} enregistrées dans {folder}.",
|
||||
"save_failed": "Impossible d’enregistrer les pièces jointes dans {folder}.",
|
||||
"popup_blocked": "Le navigateur a bloqué la nouvelle fenêtre. Autorisez les fenêtres pop-up pour ce site et réessayez.",
|
||||
"opening": "Ouverture…",
|
||||
"session_ended_title": "Vous êtes déconnecté dans cette fenêtre",
|
||||
"session_ended_body": "Ouvrez l’application et connectez-vous, puis rouvrez cette fenêtre.",
|
||||
"open_app": "Ouvrir l’application",
|
||||
"close_window": "Fermer la fenêtre",
|
||||
"message_gone_title": "Ce message n’existe plus",
|
||||
"message_gone_body": "Il a été supprimé ou déplacé vers un autre compte.",
|
||||
"message_moved": "Ce message a été déplacé vers un autre dossier.",
|
||||
"message_moved_to": "Ce message a été déplacé vers {folder}.",
|
||||
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6483,5 +6483,23 @@
|
||||
"next_issue": "שגיאת האיות או הדקדוק הבאה",
|
||||
"previous_issue": "שגיאת האיות או הדקדוק הקודמת"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "פתיחה בחלון חדש",
|
||||
"save_all_to_folder": "שמירת הכול בתיקייה…",
|
||||
"saved_all": "{count, plural, one {קובץ מצורף אחד נשמר ב-{folder}.} two {# קבצים מצורפים נשמרו ב-{folder}.} other {# קבצים מצורפים נשמרו ב-{folder}.}}",
|
||||
"saved_partial": "{saved} מתוך {total} קבצים מצורפים נשמרו ב-{folder}.",
|
||||
"save_failed": "לא ניתן לשמור את הקבצים המצורפים ב-{folder}.",
|
||||
"popup_blocked": "הדפדפן חסם את החלון החדש. יש לאפשר חלונות קופצים לאתר הזה ולנסות שוב.",
|
||||
"opening": "בפתיחה…",
|
||||
"session_ended_title": "התנתקת בחלון הזה",
|
||||
"session_ended_body": "יש לפתוח את האפליקציה ולהתחבר, ואז לפתוח את החלון הזה שוב.",
|
||||
"open_app": "פתיחת האפליקציה",
|
||||
"close_window": "סגירת החלון",
|
||||
"message_gone_title": "ההודעה הזו כבר לא קיימת",
|
||||
"message_gone_body": "היא נמחקה או הועברה לחשבון אחר.",
|
||||
"message_moved": "ההודעה הזו הועברה לתיקייה אחרת.",
|
||||
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
|
||||
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Következő helyesírási vagy nyelvtani hiba",
|
||||
"previous_issue": "Előző helyesírási vagy nyelvtani hiba"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Megnyitás új ablakban",
|
||||
"save_all_to_folder": "Összes mentése mappába…",
|
||||
"saved_all": "{count, plural, one {# melléklet mentve ide: {folder}.} other {# melléklet mentve ide: {folder}.}}",
|
||||
"saved_partial": "{total} mellékletből {saved} mentve ide: {folder}.",
|
||||
"save_failed": "A mellékleteket nem sikerült ide menteni: {folder}.",
|
||||
"popup_blocked": "A böngésző letiltotta az új ablakot. Engedélyezze az előugró ablakokat ennél a webhelynél, majd próbálja újra.",
|
||||
"opening": "Megnyitás…",
|
||||
"session_ended_title": "Ebben az ablakban ki van jelentkezve",
|
||||
"session_ended_body": "Nyissa meg az alkalmazást, jelentkezzen be, majd nyissa meg újra ezt az ablakot.",
|
||||
"open_app": "Alkalmazás megnyitása",
|
||||
"close_window": "Ablak bezárása",
|
||||
"message_gone_title": "Ez az üzenet már nincs meg",
|
||||
"message_gone_body": "Törölték, vagy másik fiókba helyezték át.",
|
||||
"message_moved": "Ezt az üzenetet másik mappába helyezték át.",
|
||||
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
|
||||
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Errore di ortografia o grammatica successivo",
|
||||
"previous_issue": "Errore di ortografia o grammatica precedente"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Apri in una nuova finestra",
|
||||
"save_all_to_folder": "Salva tutto in una cartella…",
|
||||
"saved_all": "{count, plural, one {# allegato salvato in {folder}.} other {# allegati salvati in {folder}.}}",
|
||||
"saved_partial": "{saved} allegati su {total} salvati in {folder}.",
|
||||
"save_failed": "Impossibile salvare gli allegati in {folder}.",
|
||||
"popup_blocked": "Il browser ha bloccato la nuova finestra. Consenti i pop-up per questo sito e riprova.",
|
||||
"opening": "Apertura…",
|
||||
"session_ended_title": "Hai effettuato la disconnessione in questa finestra",
|
||||
"session_ended_body": "Apri l’app e accedi, poi riapri questa finestra.",
|
||||
"open_app": "Apri l’app",
|
||||
"close_window": "Chiudi finestra",
|
||||
"message_gone_title": "Questo messaggio non c’è più",
|
||||
"message_gone_body": "È stato eliminato o spostato in un altro account.",
|
||||
"message_moved": "Questo messaggio è stato spostato in un’altra cartella.",
|
||||
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
|
||||
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "次のスペルまたは文法の問題",
|
||||
"previous_issue": "前のスペルまたは文法の問題"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "新しいウィンドウで開く",
|
||||
"save_all_to_folder": "すべてをフォルダーに保存…",
|
||||
"saved_all": "{count, plural, other {# 件の添付ファイルを {folder} に保存しました。}}",
|
||||
"saved_partial": "{total} 件中 {saved} 件の添付ファイルを {folder} に保存しました。",
|
||||
"save_failed": "添付ファイルを {folder} に保存できませんでした。",
|
||||
"popup_blocked": "ブラウザーが新しいウィンドウをブロックしました。このサイトのポップアップを許可して、もう一度お試しください。",
|
||||
"opening": "開いています…",
|
||||
"session_ended_title": "このウィンドウではサインアウトしています",
|
||||
"session_ended_body": "アプリを開いてサインインしてから、このウィンドウをもう一度開いてください。",
|
||||
"open_app": "アプリを開く",
|
||||
"close_window": "ウィンドウを閉じる",
|
||||
"message_gone_title": "このメッセージはもうありません",
|
||||
"message_gone_body": "削除されたか、別のアカウントに移動されました。",
|
||||
"message_moved": "このメッセージは別のフォルダーに移動されました。",
|
||||
"message_moved_to": "このメッセージは {folder} に移動されました。",
|
||||
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "다음 맞춤법 또는 문법 문제",
|
||||
"previous_issue": "이전 맞춤법 또는 문법 문제"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "새 창에서 열기",
|
||||
"save_all_to_folder": "모두 폴더에 저장…",
|
||||
"saved_all": "{count, plural, other {첨부 파일 #개를 {folder}에 저장했습니다.}}",
|
||||
"saved_partial": "첨부 파일 {total}개 중 {saved}개를 {folder}에 저장했습니다.",
|
||||
"save_failed": "첨부 파일을 {folder}에 저장할 수 없습니다.",
|
||||
"popup_blocked": "브라우저가 새 창을 차단했습니다. 이 사이트의 팝업을 허용한 후 다시 시도하세요.",
|
||||
"opening": "여는 중…",
|
||||
"session_ended_title": "이 창에서 로그아웃되었습니다",
|
||||
"session_ended_body": "앱을 열어 로그인한 다음 이 창을 다시 여세요.",
|
||||
"open_app": "앱 열기",
|
||||
"close_window": "창 닫기",
|
||||
"message_gone_title": "이 메시지는 더 이상 없습니다",
|
||||
"message_gone_body": "삭제되었거나 다른 계정으로 이동되었습니다.",
|
||||
"message_moved": "이 메시지는 다른 폴더로 이동되었습니다.",
|
||||
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
|
||||
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Nākamā pareizrakstības vai gramatikas kļūda",
|
||||
"previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Atvērt jaunā logā",
|
||||
"save_all_to_folder": "Saglabāt visu mapē…",
|
||||
"saved_all": "{count, plural, zero {# pielikumi saglabāti mapē {folder}.} one {# pielikums saglabāts mapē {folder}.} other {# pielikumi saglabāti mapē {folder}.}}",
|
||||
"saved_partial": "Saglabāti {saved} no {total} pielikumiem mapē {folder}.",
|
||||
"save_failed": "Pielikumus neizdevās saglabāt mapē {folder}.",
|
||||
"popup_blocked": "Pārlūks bloķēja jauno logu. Atļaujiet uznirstošos logus šai vietnei un mēģiniet vēlreiz.",
|
||||
"opening": "Notiek atvēršana…",
|
||||
"session_ended_title": "Šajā logā esat izrakstījies",
|
||||
"session_ended_body": "Atveriet lietotni un pierakstieties, pēc tam atveriet šo logu vēlreiz.",
|
||||
"open_app": "Atvērt lietotni",
|
||||
"close_window": "Aizvērt logu",
|
||||
"message_gone_title": "Šī ziņojuma vairs nav",
|
||||
"message_gone_body": "Tas tika dzēsts vai pārvietots uz citu kontu.",
|
||||
"message_moved": "Šis ziņojums tika pārvietots uz citu mapi.",
|
||||
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
|
||||
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа",
|
||||
"previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Шинэ цонхонд нээх",
|
||||
"save_all_to_folder": "Бүгдийг хавтсанд хадгалах…",
|
||||
"saved_all": "{count, plural, one {# хавсралтыг {folder} хавтсанд хадгаллаа.} other {# хавсралтыг {folder} хавтсанд хадгаллаа.}}",
|
||||
"saved_partial": "{total} хавсралтаас {saved}-г {folder} хавтсанд хадгаллаа.",
|
||||
"save_failed": "Хавсралтуудыг {folder} хавтсанд хадгалж чадсангүй.",
|
||||
"popup_blocked": "Хөтөч шинэ цонхыг хаасан байна. Энэ сайтад гарч ирэх цонхыг зөвшөөрөөд дахин оролдоно уу.",
|
||||
"opening": "Нээж байна…",
|
||||
"session_ended_title": "Та энэ цонхонд гарсан байна",
|
||||
"session_ended_body": "Аппыг нээж нэвтэрсний дараа энэ цонхыг дахин нээнэ үү.",
|
||||
"open_app": "Аппыг нээх",
|
||||
"close_window": "Цонхыг хаах",
|
||||
"message_gone_title": "Энэ мессеж байхгүй болсон",
|
||||
"message_gone_body": "Устгагдсан эсвэл өөр бүртгэл рүү зөөгдсөн.",
|
||||
"message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.",
|
||||
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
|
||||
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Neste stave- eller grammatikkfeil",
|
||||
"previous_issue": "Forrige stave- eller grammatikkfeil"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Åpne i nytt vindu",
|
||||
"save_all_to_folder": "Lagre alle i en mappe…",
|
||||
"saved_all": "{count, plural, one {# vedlegg lagret i {folder}.} other {# vedlegg lagret i {folder}.}}",
|
||||
"saved_partial": "{saved} av {total} vedlegg lagret i {folder}.",
|
||||
"save_failed": "Kunne ikke lagre vedleggene i {folder}.",
|
||||
"popup_blocked": "Nettleseren blokkerte det nye vinduet. Tillat popup-vinduer for dette nettstedet og prøv igjen.",
|
||||
"opening": "Åpner…",
|
||||
"session_ended_title": "Du er logget ut i dette vinduet",
|
||||
"session_ended_body": "Åpne appen og logg inn, og åpne deretter dette vinduet på nytt.",
|
||||
"open_app": "Åpne appen",
|
||||
"close_window": "Lukk vindu",
|
||||
"message_gone_title": "Denne meldingen er borte",
|
||||
"message_gone_body": "Den ble slettet eller flyttet til en annen konto.",
|
||||
"message_moved": "Denne meldingen ble flyttet til en annen mappe.",
|
||||
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Volgende spel- of grammaticafout",
|
||||
"previous_issue": "Vorige spel- of grammaticafout"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Openen in nieuw venster",
|
||||
"save_all_to_folder": "Alles in een map opslaan…",
|
||||
"saved_all": "{count, plural, one {# bijlage opgeslagen in {folder}.} other {# bijlagen opgeslagen in {folder}.}}",
|
||||
"saved_partial": "{saved} van {total} bijlagen opgeslagen in {folder}.",
|
||||
"save_failed": "De bijlagen konden niet in {folder} worden opgeslagen.",
|
||||
"popup_blocked": "De browser heeft het nieuwe venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.",
|
||||
"opening": "Openen…",
|
||||
"session_ended_title": "Je bent afgemeld in dit venster",
|
||||
"session_ended_body": "Open de app en meld je aan, en open dit venster daarna opnieuw.",
|
||||
"open_app": "App openen",
|
||||
"close_window": "Venster sluiten",
|
||||
"message_gone_title": "Dit bericht is er niet meer",
|
||||
"message_gone_body": "Het is verwijderd of naar een ander account verplaatst.",
|
||||
"message_moved": "Dit bericht is naar een andere map verplaatst.",
|
||||
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
|
||||
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6482,5 +6482,23 @@
|
||||
"next_issue": "Następny błąd pisowni lub gramatyki",
|
||||
"previous_issue": "Poprzedni błąd pisowni lub gramatyki"
|
||||
}
|
||||
},
|
||||
"pwaWindows": {
|
||||
"open_in_window": "Otwórz w nowym oknie",
|
||||
"save_all_to_folder": "Zapisz wszystko w folderze…",
|
||||
"saved_all": "{count, plural, one {Zapisano # załącznik w {folder}.} few {Zapisano # załączniki w {folder}.} other {Zapisano # załączników w {folder}.}}",
|
||||
"saved_partial": "Zapisano {saved} z {total} załączników w {folder}.",
|
||||
"save_failed": "Nie udało się zapisać załączników w {folder}.",
|
||||
"popup_blocked": "Przeglądarka zablokowała nowe okno. Zezwól na wyskakujące okna dla tej strony i spróbuj ponownie.",
|
||||
"opening": "Otwieranie…",
|
||||
"session_ended_title": "Wylogowano w tym oknie",
|
||||
"session_ended_body": "Otwórz aplikację i zaloguj się, a potem otwórz to okno ponownie.",
|
||||
"open_app": "Otwórz aplikację",
|
||||
"close_window": "Zamknij okno",
|
||||
"message_gone_title": "Tej wiadomości już nie ma",
|
||||
"message_gone_body": "Została usunięta lub przeniesiona na inne konto.",
|
||||
"message_moved": "Ta wiadomość została przeniesiona do innego folderu.",
|
||||
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
|
||||
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user