mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge branch 'feat/do-ux-mail-keys' into feat/do-ux-mail-fixes
This commit is contained in:
@@ -220,6 +220,42 @@ describe('DesktopHost', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// mail-25: "Search mail for '…'" on selected text in the native menu.
|
||||
describe('Search mail for selected text', () => {
|
||||
const searchFor = (text: string) => `mail.searchText:${JSON.stringify(text)}`;
|
||||
|
||||
it('runs on the mail page', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
const heard: unknown[] = [];
|
||||
const listen = (e: Event) => { e.preventDefault(); heard.push((e as CustomEvent).detail); };
|
||||
window.addEventListener('bulwark:mail-search', listen);
|
||||
try {
|
||||
shell.run(searchFor(' quarterly report \n'));
|
||||
} finally {
|
||||
window.removeEventListener('bulwark:mail-search', listen);
|
||||
}
|
||||
expect(heard).toEqual([{ text: 'quarterly report' }]);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('goes to Mail first from another app', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run(searchFor('report'));
|
||||
expect(push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('is on offer to the shell when signed in, with its label in the interface language', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'mail.searchText': true });
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
expect(shell.setLanguages.mock.lastCall![0].labels.contextSearchMail).toBe('desktop_mail.context_search_mail');
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the shell its menus in the interface language', async () => {
|
||||
const shell = fakeBridge();
|
||||
act(() => useLocaleStore.setState({ locale: 'fr' }));
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useOfflineStore } from "@/lib/offline/state";
|
||||
import { openNewComposerWindow } from "@/lib/popout/open";
|
||||
import { onReturnedComposerElsewhere } from "@/lib/popout/return-to-main";
|
||||
import { isPopoutWindow } from "@/lib/popout/window-role";
|
||||
import { parkMailSearch, requestMailSearch } from "@/lib/mail-search-request";
|
||||
import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -89,6 +90,22 @@ export function DesktopHost() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Search mail for the text selected under a right-click.
|
||||
if (id.startsWith("mail.searchText:")) {
|
||||
try {
|
||||
const text = JSON.parse(id.slice("mail.searchText:".length)) as unknown;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
const request = { text: text.trim() };
|
||||
if (!requestMailSearch(request)) {
|
||||
parkMailSearch(request);
|
||||
routerRef.current.push("/");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Only the shell sends this, and it sends JSON.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!runDesktopCommand(id)) fallback[id]?.();
|
||||
});
|
||||
}, []);
|
||||
@@ -101,6 +118,7 @@ export function DesktopHost() {
|
||||
const popout = isPopoutWindow();
|
||||
setDesktopBaseState({
|
||||
"link.check": true,
|
||||
"mail.searchText": signedIn && !popout,
|
||||
"shortcuts.singleKeys": singleKeys,
|
||||
"policy.download": canDownloadAnywhere(),
|
||||
"file.print": canPrint,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useMailSearchRequests, type MailSearchRequest } from '@/lib/mail-search-request';
|
||||
|
||||
// mail-25: a right-click on the sender in a message header did nothing (the
|
||||
// desktop app showed no menu at all).
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) => (values ? `${key} ${JSON.stringify(values)}` : key),
|
||||
}));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => (email: string) => email }));
|
||||
const requestInternalMailto = vi.hoisted(() => vi.fn((_href: string) => true));
|
||||
vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto }));
|
||||
const added = vi.hoisted(() => vi.fn(async (_email: string, _name?: string) => true));
|
||||
vi.mock('@/lib/contacts/add-from-address', () => ({ addAddressToContacts: added }));
|
||||
|
||||
const { RecipientPopover } = await import('../recipient-popover');
|
||||
|
||||
const original = useContactStore.getState();
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
function MailPage({ onSearch }: { onSearch: (request: MailSearchRequest) => void }) {
|
||||
useMailSearchRequests(onSearch, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
function rightClickSender() {
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: 'Sam Lee' }), { clientX: 40, clientY: 30 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
||||
useContactStore.setState({ contacts: [] });
|
||||
useToastStore.getState().clearToasts();
|
||||
requestInternalMailto.mockClear();
|
||||
added.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useContactStore.setState(original);
|
||||
});
|
||||
|
||||
describe('right-click on an address in a message header', () => {
|
||||
it('offers Copy address, New message, Add to contacts and Find emails from', () => {
|
||||
render(<><RecipientPopover name="Sam Lee" email="sam@example.org" /><MailPage onSearch={() => {}} /></>);
|
||||
rightClickSender();
|
||||
const items = screen.getAllByRole('menuitem').map((item) => item.textContent);
|
||||
expect(items).toEqual([
|
||||
'desktopUi.context.copy_address',
|
||||
'email_composer.new_message',
|
||||
'email_viewer.contact_sidebar.add_to_contacts',
|
||||
'context_menu.find_emails_from {"name":"Sam Lee"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies the address', async () => {
|
||||
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
|
||||
rightClickSender();
|
||||
fireEvent.click(screen.getByTestId('address-menu-copy'));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('sam@example.org'));
|
||||
});
|
||||
|
||||
it('writes to it in the app\'s composer', () => {
|
||||
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
|
||||
rightClickSender();
|
||||
fireEvent.click(screen.getByTestId('address-menu-write'));
|
||||
expect(requestInternalMailto).toHaveBeenCalledWith('mailto:sam@example.org');
|
||||
});
|
||||
|
||||
it('adds it to the contacts, with the name from the header', async () => {
|
||||
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
|
||||
rightClickSender();
|
||||
fireEvent.click(screen.getByTestId('address-menu-contact'));
|
||||
await waitFor(() => expect(added).toHaveBeenCalledWith('sam@example.org', 'Sam Lee'));
|
||||
});
|
||||
|
||||
it('a saved contact opens instead of being added again', () => {
|
||||
useContactStore.setState({ contacts: [{ id: 'c1', emails: { e: { address: 'SAM@example.org' } } }] as never });
|
||||
const onViewContact = vi.fn();
|
||||
render(<RecipientPopover name="Sam Lee" email="sam@example.org" onViewContact={onViewContact} />);
|
||||
rightClickSender();
|
||||
expect(screen.getByTestId('address-menu-contact').textContent).toBe('email_viewer.contact_sidebar.view_contact');
|
||||
fireEvent.click(screen.getByTestId('address-menu-contact'));
|
||||
expect(onViewContact).toHaveBeenCalledWith(expect.objectContaining({ id: 'c1' }), 'sam@example.org');
|
||||
expect(added).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds the mail from this sender on the mail page', () => {
|
||||
const onSearch = vi.fn();
|
||||
render(<><RecipientPopover name="Sam Lee" email="sam@example.org" /><MailPage onSearch={onSearch} /></>);
|
||||
rightClickSender();
|
||||
act(() => fireEvent.click(screen.getByTestId('address-menu-search')));
|
||||
expect(onSearch).toHaveBeenCalledWith({ from: 'sam@example.org' });
|
||||
});
|
||||
|
||||
it('leaves out the search where there is no mail page (a message window)', () => {
|
||||
render(<RecipientPopover name="Sam Lee" email="sam@example.org" />);
|
||||
rightClickSender();
|
||||
expect(screen.queryByTestId('address-menu-search')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// mail-3: a key pressed after a click into an HTML message of a conversation
|
||||
// stayed in the message's frame, so the mail shortcuts did nothing.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k });
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => t,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
|
||||
|
||||
function renderHtmlMessage() {
|
||||
const email = {
|
||||
id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false,
|
||||
htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [],
|
||||
bodyValues: { '1': { value: '<p id="p">Newsletter</p>' } },
|
||||
};
|
||||
const { container } = render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [email], latestEmail: email } as never}
|
||||
emails={[email] as never}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const srcdoc = iframe.getAttribute('srcdoc') ?? '';
|
||||
const doc = iframe.contentDocument!;
|
||||
doc.body.innerHTML = /<body[^>]*>([\s\S]*)<\/body>/.exec(srcdoc)![1];
|
||||
fireEvent.load(iframe);
|
||||
return doc;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('keys in a conversation message', () => {
|
||||
it('reach the page, where the shortcut handlers listen', () => {
|
||||
const doc = renderHtmlMessage();
|
||||
const heard: string[] = [];
|
||||
const listen = (e: KeyboardEvent) => heard.push(e.key);
|
||||
window.addEventListener('keydown', listen);
|
||||
try {
|
||||
const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent;
|
||||
doc.getElementById('p')!.dispatchEvent(new FrameKeyboardEvent('keydown', { key: 'j', code: 'KeyJ', bubbles: true, cancelable: true }));
|
||||
} finally {
|
||||
window.removeEventListener('keydown', listen);
|
||||
}
|
||||
expect(heard).toEqual(['j']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
// The right-click menu on an address in a message header: copy it, write to
|
||||
// it, keep it as a contact, find the mail it sent. In the desktop app the
|
||||
// menu shows natively (components/ui/context-menu).
|
||||
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, type ContextMenuPosition } from "@/components/ui/context-menu";
|
||||
import { Copy, ExternalLink, Search, Send, UserPlus } from "@/components/icons";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { addAddressToContacts } from "@/lib/contacts/add-from-address";
|
||||
import { mailSearchAvailable, requestMailSearch } from "@/lib/mail-search-request";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
export interface MenuAddress {
|
||||
name?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AddressContextMenuProps {
|
||||
address: MenuAddress | null;
|
||||
isOpen: boolean;
|
||||
position: ContextMenuPosition;
|
||||
onClose: () => void;
|
||||
/** Shows a saved contact; without it a saved address offers nothing more. */
|
||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||
}
|
||||
|
||||
function contactFor(contacts: ContactCard[], email: string): ContactCard | undefined {
|
||||
const wanted = email.toLowerCase();
|
||||
return contacts.find((c) => c.emails && Object.values(c.emails).some((e) => e.address.toLowerCase() === wanted));
|
||||
}
|
||||
|
||||
export function AddressContextMenu({ address, isOpen, position, onClose, onViewContact }: AddressContextMenuProps) {
|
||||
const t = useTranslations();
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
if (!address) return null;
|
||||
const { email, name } = address;
|
||||
const contact = contactFor(contacts, email);
|
||||
const run = (action: () => void) => () => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
toast.success(t("email_viewer.contact_sidebar.copied"));
|
||||
} catch {
|
||||
toast.error(t("email_viewer.contact_sidebar.copy_failed"));
|
||||
}
|
||||
};
|
||||
const write = () => {
|
||||
const href = `mailto:${email}`;
|
||||
// No mail page here to take it (a message window): the system's handler.
|
||||
if (!requestInternalMailto(href)) window.location.href = href;
|
||||
};
|
||||
const save = () => {
|
||||
void addAddressToContacts(email, name).then((created) => {
|
||||
if (created) toast.success(t("email_viewer.contact_added"));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu isOpen={isOpen} position={position} onClose={onClose} label={name || email}>
|
||||
<ContextMenuItem icon={Copy} label={t("desktopUi.context.copy_address")} onClick={run(() => void copy())} testId="address-menu-copy" />
|
||||
<ContextMenuItem icon={Send} label={t("email_composer.new_message")} onClick={run(write)} testId="address-menu-write" />
|
||||
{contact ? (
|
||||
onViewContact && (
|
||||
<ContextMenuItem
|
||||
icon={ExternalLink}
|
||||
label={t("email_viewer.contact_sidebar.view_contact")}
|
||||
onClick={run(() => onViewContact(contact, email))}
|
||||
testId="address-menu-contact"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<ContextMenuItem icon={UserPlus} label={t("email_viewer.contact_sidebar.add_to_contacts")} onClick={run(save)} testId="address-menu-contact" />
|
||||
)}
|
||||
{mailSearchAvailable() && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Search}
|
||||
label={t("context_menu.find_emails_from", { name: name || email })}
|
||||
onClick={run(() => requestMailSearch({ from: email }))}
|
||||
testId="address-menu-search"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** A right-click menu for one address, for the element that shows it. */
|
||||
export function useAddressContextMenu(onViewContact?: (contact: ContactCard | null, email: string) => void): {
|
||||
onContextMenu: (e: MouseEvent, address: MenuAddress) => void;
|
||||
menu: ReactNode;
|
||||
} {
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<MenuAddress>();
|
||||
return {
|
||||
onContextMenu: (e, address) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openContextMenu(e, address);
|
||||
},
|
||||
menu: (
|
||||
<AddressContextMenu
|
||||
address={contextMenu.data}
|
||||
isOpen={contextMenu.isOpen}
|
||||
position={contextMenu.position}
|
||||
onClose={closeContextMenu}
|
||||
onViewContact={onViewContact}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** An address as text, with the address menu on a right-click. */
|
||||
export function AddressText({ name, email, onViewContact }: MenuAddress & { onViewContact?: (contact: ContactCard | null, email: string) => void }) {
|
||||
const { onContextMenu, menu } = useAddressContextMenu(onViewContact);
|
||||
return (
|
||||
<>
|
||||
<span onContextMenu={(e) => onContextMenu(e, { name, email })}>{email}</span>
|
||||
{menu}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, re
|
||||
import { getAttachmentDisplayName } from "@/lib/attachment-name";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { measureEmailFrameHeight } from "@/lib/email-frame-height";
|
||||
import { forwardFrameKeys } from "@/lib/email-frame-keys";
|
||||
import { addAddressToContacts } from "@/lib/contacts/add-from-address";
|
||||
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
|
||||
import { fitEmailBodyWidth, type FitOptions } from "@/lib/email-fit-width";
|
||||
import { hasFullBleedCanvas } from "@/lib/email-full-bleed";
|
||||
@@ -47,7 +49,7 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
|
||||
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
|
||||
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
|
||||
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
@@ -158,6 +160,7 @@ import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
|
||||
import { AddressText } from "./address-context-menu";
|
||||
import { mentionFromLink } from "@/lib/recipient-mentions";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
@@ -3775,6 +3778,8 @@ export function EmailViewer({
|
||||
onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); },
|
||||
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }),
|
||||
});
|
||||
// j, k, e and the other shortcuts keep working after a click into the body.
|
||||
forwardFrameKeys(doc, iframe);
|
||||
|
||||
}
|
||||
} catch {
|
||||
@@ -5226,7 +5231,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</div>
|
||||
{/* Email address under name (on the name's line under Mountain View) */}
|
||||
{!mvViewer && sender?.email && sender?.name && (
|
||||
<div className="text-sm text-muted-foreground mt-0.5 truncate">{sender.email}</div>
|
||||
<div className="text-sm text-muted-foreground mt-0.5 truncate"><AddressText name={sender.name} email={sender.email} onViewContact={handleViewContactSidebar} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6383,24 +6388,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
setContactSidebarEmail(null);
|
||||
} : undefined}
|
||||
onAddToContacts={(addr, name) => {
|
||||
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
|
||||
const client = useAuthStore.getState().client;
|
||||
const contactData: Partial<ContactCard> = {
|
||||
emails: { email: { address: addr } },
|
||||
// `full` feeds the mandatory vCard FN — strict CardDAV clients
|
||||
// (Apple Contacts) drop cards without it (#430).
|
||||
...(name ? { name: { components: name.includes(' ')
|
||||
? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
|
||||
: [{ kind: 'given' as const, value: name }],
|
||||
isOrdered: true, full: name,
|
||||
}} : {}),
|
||||
};
|
||||
if (client && supportsSync) {
|
||||
createContact(client, contactData).then((created) => { if (created) toast.success(t('contact_added')); });
|
||||
} else {
|
||||
void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
|
||||
.then((created) => { if (created) toast.success(t('contact_added')); });
|
||||
}
|
||||
void addAddressToContacts(addr, name).then((created) => { if (created) toast.success(t('contact_added')); });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { useAddressContextMenu } from "./address-context-menu";
|
||||
|
||||
const CARD_WIDTH = 300;
|
||||
|
||||
@@ -260,6 +261,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const shownEmail = useOwnDomainAddress()(email);
|
||||
const addressMenu = useAddressContextMenu(onViewContact);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (anchor) {
|
||||
@@ -275,6 +277,10 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={handleToggle}
|
||||
onContextMenu={(e) => {
|
||||
setAnchor(null);
|
||||
addressMenu.onContextMenu(e, { name, email });
|
||||
}}
|
||||
className={cn(
|
||||
"text-foreground hover:text-primary hover:underline cursor-pointer transition-colors min-w-0 break-words text-start",
|
||||
className
|
||||
@@ -293,6 +299,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
ignoreRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
{addressMenu.menu}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ReceivedLabelChip } from "./received-label-chip";
|
||||
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { measureEmailFrameHeight } from "@/lib/email-frame-height";
|
||||
import { forwardFrameKeys } from "@/lib/email-frame-keys";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { mailLanguage } from "@/lib/mail-language";
|
||||
import { htmlToReadableText } from "@/lib/html-to-readable-text";
|
||||
@@ -926,6 +927,8 @@ export function ConversationEmailBody({
|
||||
emailId: email.id,
|
||||
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }),
|
||||
});
|
||||
// j, k, e and the other shortcuts keep working after a click into the body.
|
||||
forwardFrameKeys(doc, iframe);
|
||||
setHtmlBodyEl(doc.body);
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
|
||||
@@ -110,6 +110,7 @@ import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useAppCommands } from "@/lib/app-commands";
|
||||
import { useMailSearchRequests } from "@/lib/mail-search-request";
|
||||
import { MvSearchForm } from "@/components/search/mv-search-form";
|
||||
import { MvSidePanel } from "@/components/layout/mv-side-panel";
|
||||
import { PluginSidePanel } from "@/components/plugins/plugin-side-panels";
|
||||
@@ -1329,6 +1330,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
else backToList();
|
||||
},
|
||||
}, isAuthenticated && !!client);
|
||||
// "Find emails from …" on an address in a message, and the desktop app's
|
||||
// "Search mail for …" on selected text.
|
||||
useMailSearchRequests((request) => {
|
||||
if ("from" in request) void handleSelectContactSuggestion({ name: "", email: request.from }, "from");
|
||||
else void handleSearch(request.text);
|
||||
}, isAuthenticated && !!client);
|
||||
|
||||
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
|
||||
// from what's on screen (lib/desktop/commands.ts).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
// mail-26: the desktop app's "Bulwark keeps your mail on this device" notice
|
||||
// stayed over the quick reply box until someone closed it.
|
||||
|
||||
const notifier = vi.hoisted(() => ({ show: null as ((notice: OfflineNotice) => void) | null }));
|
||||
vi.mock('@/lib/offline/offline-mode', () => ({
|
||||
setOfflineNotifier: (fn: ((notice: OfflineNotice) => void) | null) => {
|
||||
notifier.show = fn;
|
||||
},
|
||||
startOfflineMode: () => () => {},
|
||||
confirmLoss: () => {},
|
||||
unsentCount: async () => ({ messages: 0, changes: 0 }),
|
||||
}));
|
||||
|
||||
const { OfflineHost } = await import('../offline-host');
|
||||
|
||||
beforeEach(() => {
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('the notice that the desktop app keeps mail on this device', () => {
|
||||
it('goes away by itself, and still offers Settings', () => {
|
||||
render(<OfflineHost />);
|
||||
notifier.show!({ kind: 'default-on' });
|
||||
const [notice] = useToastStore.getState().toasts;
|
||||
expect(notice.title).toBe('default_on');
|
||||
expect(notice.duration).toBeGreaterThan(0);
|
||||
expect(notice.action?.label).toBe('open_settings');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,9 @@ import { buildSettingsPath } from '@/lib/deep-links';
|
||||
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
/** How long the notice that the desktop app keeps mail stays: a long sentence, so twice a plain toast. */
|
||||
const DEFAULT_ON_NOTICE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
|
||||
* the outbox, the wipes. Its notices become toasts, and a sign-out the
|
||||
@@ -74,7 +77,8 @@ export function OfflineHost() {
|
||||
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
|
||||
break;
|
||||
case 'default-on':
|
||||
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
|
||||
// Said once; it goes like other notices, and Settings has the switch.
|
||||
toast.info(tStorageRef.current('default_on'), { duration: DEFAULT_ON_NOTICE_MS, action: openStorage });
|
||||
break;
|
||||
case 'legacy-stranded':
|
||||
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { setLockedHere } from '@/lib/app-lock/state';
|
||||
import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { announceMovedToMainWindow, openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type PopoutMovedNotice, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { clearUndoHistory, undoHistory, undoLatest } from '@/lib/undo-history';
|
||||
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
|
||||
import { takeReturnedComposer } from '@/lib/popout/return-to-main';
|
||||
import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed';
|
||||
@@ -458,3 +459,59 @@ describe('a composer coming back', () => {
|
||||
expect(takeReturnedComposer()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// mail-4: Archive or Delete in a message window closed the window and its
|
||||
// toast with it, and Ctrl+Z in the main window didn't reach the move.
|
||||
describe('a move a message window made', () => {
|
||||
const NOTICE: PopoutMovedNotice = {
|
||||
kind: 'archive',
|
||||
batches: [{ login: 'alice', points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }],
|
||||
};
|
||||
|
||||
it('gets its toast with Undo here, and Ctrl+Z puts the message back through this window\'s login', async () => {
|
||||
const restoreEmails = vi.fn(async () => {});
|
||||
Object.assign(client, { restoreEmails });
|
||||
clearUndoHistory();
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
let taken = false;
|
||||
await act(async () => {
|
||||
taken = await announceMovedToMainWindow(NOTICE, 2000);
|
||||
});
|
||||
expect(taken).toBe(true);
|
||||
const toasts = useToastStore.getState().toasts;
|
||||
expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_archived']);
|
||||
expect(toasts[0].action?.label).toBe('notifications.undo');
|
||||
expect(undoHistory()).toHaveLength(1);
|
||||
await act(async () => {
|
||||
undoLatest();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
expect(restoreEmails).toHaveBeenCalledWith([{ id: 'm1', mailboxIds: { inbox: true }, seen: true }], undefined);
|
||||
});
|
||||
|
||||
it('is taken by one main window of two', async () => {
|
||||
clearUndoHistory();
|
||||
render(<><PopoutHost /><PopoutHost /></>);
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await announceMovedToMainWindow(NOTICE, 2000);
|
||||
});
|
||||
await settle();
|
||||
expect(useToastStore.getState().toasts).toHaveLength(1);
|
||||
expect(undoHistory()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a delete for good still says so, without Undo', async () => {
|
||||
clearUndoHistory();
|
||||
render(<PopoutHost />);
|
||||
await settle();
|
||||
await act(async () => {
|
||||
await announceMovedToMainWindow({ kind: 'delete', batches: [] }, 2000);
|
||||
});
|
||||
const toasts = useToastStore.getState().toasts;
|
||||
expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_deleted']);
|
||||
expect(toasts[0].action).toBeUndefined();
|
||||
expect(undoHistory()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { takeOverMoved } from "@/lib/popout/moved-undo";
|
||||
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
|
||||
import { commitReturnedComposer, dropReturnedComposer, offerReturnedComposer } from "@/lib/popout/return-to-main";
|
||||
import { credentialFingerprint, sealFor } from "@/lib/popout/sealed";
|
||||
@@ -164,6 +165,14 @@ export function PopoutHost() {
|
||||
takeOverSent(message.notice);
|
||||
});
|
||||
break;
|
||||
case "moved":
|
||||
// Undo for what a message window archived, deleted or moved: it has closed.
|
||||
claimOnce(`bulwark-popout-moved:${message.requestId}`, () => {
|
||||
channel?.post({ type: "moved-ack", requestId: message.requestId });
|
||||
takeOverMoved(message.notice, (key, values) => tRef.current(`notifications.${key}`, values));
|
||||
refreshList();
|
||||
});
|
||||
break;
|
||||
case "draft-rescue":
|
||||
startRescue(message.draft);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* mail-4: Archive or Delete in a message window closed the window, its toast
|
||||
* went with it, and nothing could undo the move. The view now hands the
|
||||
* restore points to offerMoveUndo (lib/popout/moved-undo), which gives them
|
||||
* to the main window, before the window closes.
|
||||
*/
|
||||
|
||||
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: (props: Record<string, unknown>) => {
|
||||
viewerProps.current = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('@/components/error', () => ({
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
EmailViewerErrorFallback: () => null,
|
||||
}));
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
|
||||
vi.mock('@/lib/mail-removal-confirm', () => ({ confirmMailRemoval: async () => true }));
|
||||
|
||||
const calls = vi.hoisted(() => ({ order: [] as string[], offer: [] as unknown[][] }));
|
||||
vi.mock('@/lib/popout/moved-undo', () => ({
|
||||
offerMoveUndo: async (...args: unknown[]) => {
|
||||
calls.order.push(`offer:${args[0]}`);
|
||||
calls.offer.push(args);
|
||||
},
|
||||
}));
|
||||
const role = vi.hoisted(() => ({ popout: true }));
|
||||
vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/popout/window-role')>(),
|
||||
isPopoutWindow: () => role.popout,
|
||||
}));
|
||||
|
||||
function selectorStore<T extends object>(state: T) {
|
||||
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
|
||||
hook.getState = () => state;
|
||||
return hook;
|
||||
}
|
||||
|
||||
const auth = vi.hoisted(() => ({ state: { client: null as unknown, activeAccountId: 'alice', getClientForAccount: () => undefined } }));
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
|
||||
|
||||
const MESSAGE = vi.hoisted(() => ({
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ email: 'sam@example.org', name: 'Sam' }],
|
||||
to: [{ email: 'me@example.org' }],
|
||||
subject: 'Launch day!',
|
||||
bodyValues: { '1': { value: 'Hi' } },
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '1', type: 'text/plain' }],
|
||||
attachments: [],
|
||||
}));
|
||||
const permanent = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock('@/stores/email-store', () => ({
|
||||
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }),
|
||||
restoreBatchesFor: (emails: Array<{ id: string; mailboxIds: Record<string, boolean> }>, client: unknown) =>
|
||||
[{ client, points: emails.map((e) => ({ id: e.id, mailboxIds: e.mailboxIds, seen: true })) }],
|
||||
bulkDeleteIsPermanent: () => permanent.value,
|
||||
useEmailStore: selectorStore({
|
||||
fetchEmailContent: async () => MESSAGE,
|
||||
deleteEmail: async () => { calls.order.push('deleted'); return true; },
|
||||
moveToMailbox: async () => { calls.order.push('moved'); return true; },
|
||||
markAsRead: async () => true,
|
||||
toggleStar: async () => true,
|
||||
setEmailKeywordsLocal: () => {},
|
||||
mailboxes: [{ id: 'inbox', role: 'inbox', name: 'Inbox' }, { id: 'archive', role: 'archive', name: 'Archive' }],
|
||||
emails: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: 'inbox',
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
|
||||
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({}) }));
|
||||
vi.mock('@/stores/pro-tab-store', () => ({ useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }) }));
|
||||
|
||||
import { ProEmailView } from '../pro-email-tab-body';
|
||||
|
||||
async function open() {
|
||||
const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'me@example.org', getAccountId: () => 'a' };
|
||||
const onClose = vi.fn(() => calls.order.push('closed'));
|
||||
render(<ProEmailView emailId="m1" client={client as never} clientAccountId="alice" onClose={onClose} />);
|
||||
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
|
||||
return { client, onClose };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
viewerProps.current = null;
|
||||
calls.order = [];
|
||||
calls.offer = [];
|
||||
role.popout = true;
|
||||
permanent.value = false;
|
||||
});
|
||||
|
||||
describe('a move out of a message window', () => {
|
||||
it('Archive hands its Undo on before the window closes', async () => {
|
||||
const { client } = await open();
|
||||
await (viewerProps.current!.onArchive as () => Promise<void>)();
|
||||
await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:archive', 'closed']));
|
||||
const [, offeredClient, restore, , inOwnWindow] = calls.offer[0];
|
||||
expect(offeredClient).toBe(client);
|
||||
expect(restore).toEqual([{ client, points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }] }]);
|
||||
expect(inOwnWindow).toBe(true);
|
||||
});
|
||||
|
||||
it('Delete does too', async () => {
|
||||
await open();
|
||||
await (viewerProps.current!.onDelete as () => Promise<void>)();
|
||||
await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed']));
|
||||
expect((calls.offer[0][2] as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it('a delete for good has nothing to undo', async () => {
|
||||
permanent.value = true;
|
||||
await open();
|
||||
await (viewerProps.current!.onDelete as () => Promise<void>)();
|
||||
await waitFor(() => expect(calls.order).toEqual(['deleted', 'offer:delete', 'closed']));
|
||||
expect(calls.offer[0][2]).toBeNull();
|
||||
});
|
||||
|
||||
it('Move to a folder does too', async () => {
|
||||
await open();
|
||||
await (viewerProps.current!.onMoveToMailbox as (id: string) => Promise<void>)('archive');
|
||||
await waitFor(() => expect(calls.order).toEqual(['moved', 'offer:move', 'closed']));
|
||||
});
|
||||
|
||||
it('in a tab of the main window the Undo stays in this window', async () => {
|
||||
role.popout = false;
|
||||
await open();
|
||||
await (viewerProps.current!.onArchive as () => Promise<void>)();
|
||||
await waitFor(() => expect(calls.offer).toHaveLength(1));
|
||||
expect(calls.offer[0][4]).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, bulkDeleteIsPermanent, restoreBatchesFor, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
@@ -39,6 +39,7 @@ import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
|
||||
import { isFilePreviewable } from '@/lib/file-preview';
|
||||
import { useDesktopCommands } from '@/lib/desktop/commands';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import { offerMoveUndo } from '@/lib/popout/moved-undo';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Only needed when the device policy turns downloads off.
|
||||
@@ -384,23 +385,30 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
if (!(await confirmMailRemoval('delete', 1, confirmDialog, tRemove))) return;
|
||||
// Where it was, for Undo; a delete for good has none.
|
||||
const { selectedMailbox } = useEmailStore.getState();
|
||||
const restore = bulkDeleteIsPermanent(mailboxes.find((m) => m.id === selectedMailbox)) ? null : restoreBatchesFor([email], client);
|
||||
try {
|
||||
// false: not deleted (a plugin stopped it), so the tab stays open.
|
||||
if (await deleteEmail(client, email.id)) onClose();
|
||||
if (await deleteEmail(client, email.id)) {
|
||||
await offerMoveUndo('delete', client, restore, tNotifications, isPopoutWindow());
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
toast.error(tNotifications('error_deleting'));
|
||||
}
|
||||
}, [client, email, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
|
||||
}, [client, email, mailboxes, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
const archiveMb = mailboxes.find((m) => m.role === 'archive');
|
||||
if (!archiveMb) return;
|
||||
if (!(await confirmMailRemoval('archive', 1, confirmDialog, tRemove))) return;
|
||||
const restore = restoreBatchesFor([email], client);
|
||||
try {
|
||||
if (!(await moveToMailbox(client, email.id, archiveMb.id, emailKeyFor(email)))) return;
|
||||
toast.success(tNotifications('email_archived'));
|
||||
await offerMoveUndo('archive', client, restore, tNotifications, isPopoutWindow());
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Archive failed:', err);
|
||||
@@ -494,12 +502,16 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
const restore = restoreBatchesFor([email], client);
|
||||
try {
|
||||
if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) onClose();
|
||||
if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) {
|
||||
await offerMoveUndo('move', client, restore, tNotifications, isPopoutWindow());
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err);
|
||||
}
|
||||
}, [client, email, moveToMailbox, onClose]);
|
||||
}, [client, email, moveToMailbox, onClose, tNotifications]);
|
||||
|
||||
// This view's message answers the same keys as a message in the mail tab,
|
||||
// while its tab is the one in front. c comes from the mail page, as on any tab.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { forwardFrameKeys } from '../email-frame-keys';
|
||||
|
||||
// mail-3: after a click into an HTML message, j, k, e, # and the rest did
|
||||
// nothing, since the key presses went to the body's frame and never reached
|
||||
// the page's shortcut handlers.
|
||||
|
||||
const email = { id: 'm1' } as never;
|
||||
|
||||
function frameWith(html: string) {
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.appendChild(frame);
|
||||
const doc = frame.contentDocument!;
|
||||
doc.body.innerHTML = html;
|
||||
forwardFrameKeys(doc, frame);
|
||||
return { frame, doc };
|
||||
}
|
||||
|
||||
/** A key press as the frame's own window makes it. */
|
||||
function pressIn(doc: Document, target: Element, key: string, code: string, init: KeyboardEventInit = {}) {
|
||||
const FrameKeyboardEvent = (doc.defaultView as unknown as { KeyboardEvent: typeof KeyboardEvent }).KeyboardEvent;
|
||||
const event = new FrameKeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...init });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
function mountShortcuts() {
|
||||
const handlers = { onNextEmail: vi.fn(), onArchive: vi.fn(), onDelete: vi.fn(), onCloseEmail: vi.fn(), onSelectAll: vi.fn() };
|
||||
renderHook(() => useKeyboardShortcuts({ emails: [email], selectedEmailId: 'm1', handlers }));
|
||||
return handlers;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('keys pressed in a message body', () => {
|
||||
it('reach the app shortcuts: j, e and #', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const { doc } = frameWith('<p>Newsletter</p>');
|
||||
const p = doc.querySelector('p')!;
|
||||
const j = pressIn(doc, p, 'j', 'KeyJ');
|
||||
pressIn(doc, p, 'e', 'KeyE');
|
||||
pressIn(doc, p, '#', 'Digit3', { shiftKey: true });
|
||||
expect(handlers.onNextEmail).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onArchive).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onDelete).toHaveBeenCalledTimes(1);
|
||||
// The frame doesn't act on a key a shortcut took.
|
||||
expect(j.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('stay in a form inside the mail', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const { doc } = frameWith('<form><input name="q"><textarea></textarea><div contenteditable="true"><b>x</b></div></form>');
|
||||
pressIn(doc, doc.querySelector('input')!, 'j', 'KeyJ');
|
||||
pressIn(doc, doc.querySelector('textarea')!, 'e', 'KeyE');
|
||||
pressIn(doc, doc.querySelector('b')!, 'Backspace', 'Backspace');
|
||||
expect(handlers.onNextEmail).not.toHaveBeenCalled();
|
||||
expect(handlers.onArchive).not.toHaveBeenCalled();
|
||||
expect(handlers.onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leave Ctrl+A, Ctrl+C and the scrolling keys to the body', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const { doc } = frameWith('<p>Text</p>');
|
||||
const p = doc.querySelector('p')!;
|
||||
const selectAll = pressIn(doc, p, 'a', 'KeyA', { ctrlKey: true });
|
||||
const copy = pressIn(doc, p, 'c', 'KeyC', { ctrlKey: true });
|
||||
const space = pressIn(doc, p, ' ', 'Space');
|
||||
const down = pressIn(doc, p, 'ArrowDown', 'ArrowDown');
|
||||
// Ctrl+A in the list selects every message; in the body it selects the text.
|
||||
expect(handlers.onSelectAll).not.toHaveBeenCalled();
|
||||
expect(handlers.onNextEmail).not.toHaveBeenCalled();
|
||||
for (const event of [selectAll, copy, space, down]) expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('Escape puts the focus back on the open message in the list', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const list = document.createElement('div');
|
||||
list.id = 'message-list';
|
||||
list.innerHTML = '<div role="row" tabindex="-1">A</div><div role="row" tabindex="-1" aria-current="true">B</div>';
|
||||
document.body.appendChild(list);
|
||||
const { doc } = frameWith('<p>Text</p>');
|
||||
pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape');
|
||||
expect(document.activeElement?.textContent).toBe('B');
|
||||
// Only the focus moves: the message stays open.
|
||||
expect(handlers.onCloseEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape without a list (a message window) is the app\'s Escape', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const { doc } = frameWith('<p>Text</p>');
|
||||
pressIn(doc, doc.querySelector('p')!, 'Escape', 'Escape');
|
||||
expect(handlers.onCloseEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stop while a menu is open on the page', () => {
|
||||
const handlers = mountShortcuts();
|
||||
const menu = document.createElement('div');
|
||||
menu.setAttribute('role', 'menu');
|
||||
document.body.appendChild(menu);
|
||||
const { doc } = frameWith('<p>Text</p>');
|
||||
pressIn(doc, doc.querySelector('p')!, 'e', 'KeyE');
|
||||
expect(handlers.onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
|
||||
/**
|
||||
* Saves an address from a message as a new contact: on the server when the
|
||||
* account syncs contacts, in this browser otherwise. True when it was saved.
|
||||
*/
|
||||
export async function addAddressToContacts(address: string, name?: string): Promise<boolean> {
|
||||
const { createContact, addLocalContact, supportsSync } = useContactStore.getState();
|
||||
const client = useAuthStore.getState().client;
|
||||
const contactData: Partial<ContactCard> = {
|
||||
emails: { email: { address } },
|
||||
// `full` feeds the mandatory vCard FN — strict CardDAV clients
|
||||
// (Apple Contacts) drop cards without it (#430).
|
||||
...(name ? { name: { components: name.includes(' ')
|
||||
? [{ kind: 'given' as const, value: name.split(' ')[0] }, { kind: 'surname' as const, value: name.split(' ').slice(1).join(' ') }]
|
||||
: [{ kind: 'given' as const, value: name }],
|
||||
isOrdered: true, full: name,
|
||||
}} : {}),
|
||||
};
|
||||
const created = client && supportsSync
|
||||
? await createContact(client, contactData)
|
||||
: await addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
|
||||
return !!created;
|
||||
}
|
||||
@@ -139,6 +139,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
contextCopyAddress: t('desktopUi.context.copy_address'),
|
||||
contextSaveImage: t('desktopUi.context.save_image'),
|
||||
contextCopyImage: t('desktopUi.context.copy_image'),
|
||||
contextSearchMail: t('desktop_mail.context_search_mail', { text: '{text}' }),
|
||||
unsavedTitle: t('desktopUi.closing.unsaved_title'),
|
||||
unsavedDetail: t('desktopUi.closing.unsaved_detail'),
|
||||
closeAnyway: t('desktopUi.closing.close_anyway'),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* An HTML message renders in a frame, and a key pressed after a click into it
|
||||
* goes to the frame's own window: j, k, e, # and the rest stopped working
|
||||
* until the user clicked back into the list. This hands those keys on to the
|
||||
* page as a copy of the key press, sent from the frame element, so the app's
|
||||
* shortcut handlers (hooks/use-keyboard-shortcuts, hooks/use-undo-shortcut)
|
||||
* take it with their usual checks: an open menu or dialog, single-key
|
||||
* shortcuts turned off. Escape puts the focus back on the message's row in
|
||||
* the list, as Escape in a dialog gives the focus back.
|
||||
*
|
||||
* Left to the frame: anything typed into a form in the mail, keys with Ctrl,
|
||||
* Alt or ⌘ (copy, select all), and the keys that scroll the body or move
|
||||
* through its links (arrows, Space, Tab, Enter, Page Up and Down).
|
||||
*/
|
||||
|
||||
import { focusTemporarily } from "@/lib/keyboard";
|
||||
|
||||
/** Keys that aren't one character but are mail shortcuts. */
|
||||
const NAMED_KEYS = new Set(["Delete", "Backspace", "Escape"]);
|
||||
|
||||
const EDITABLE =
|
||||
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
|
||||
|
||||
/**
|
||||
* Whether a key press in the frame came from a place that takes text. By tag
|
||||
* and attribute: the frame's elements belong to another window, so
|
||||
* `instanceof HTMLElement` (lib/keyboard) is false for all of them.
|
||||
*/
|
||||
function typedInFrame(event: KeyboardEvent, doc: Document): boolean {
|
||||
if (doc.designMode === "on") return true;
|
||||
const target = event.target as Node | null;
|
||||
const el = target && target.nodeType === 1 ? (target as Element) : target?.parentElement ?? null;
|
||||
if (!el) return false;
|
||||
if (el.closest(EDITABLE)) return true;
|
||||
return (el as HTMLElement).isContentEditable === true;
|
||||
}
|
||||
|
||||
/** Whether this key press in the frame belongs to the app. */
|
||||
export function isFrameKeyForApp(event: KeyboardEvent, doc: Document): boolean {
|
||||
if (event.defaultPrevented || event.isComposing) return false;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||
if (event.key === " " || (event.key.length !== 1 && !NAMED_KEYS.has(event.key))) return false;
|
||||
return !typedInFrame(event, doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open message's row in the message list, or the list itself when no row
|
||||
* is open. Null where there is no list (a message window).
|
||||
*/
|
||||
function listFocusTarget(doc: Document): HTMLElement | null {
|
||||
const list = doc.getElementById("message-list");
|
||||
if (!list) return null;
|
||||
return (
|
||||
list.querySelector<HTMLElement>('[role="row"][aria-current="true"]') ??
|
||||
list.querySelector<HTMLElement>('[role="row"][tabindex="0"]') ??
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the app's keys from a message frame's document to the page. Returns a
|
||||
* function that stops it; a frame whose document is replaced stops by itself.
|
||||
*/
|
||||
export function forwardFrameKeys(doc: Document, frame: HTMLIFrameElement): () => void {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isFrameKeyForApp(event, doc)) return;
|
||||
if (event.key === "Escape") {
|
||||
const target = listFocusTarget(frame.ownerDocument);
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
focusTemporarily(target, { preventScroll: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const copy = new KeyboardEvent("keydown", {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey,
|
||||
repeat: event.repeat,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
frame.dispatchEvent(copy);
|
||||
// A shortcut ran: the frame doesn't act on the key as well.
|
||||
if (copy.defaultPrevented) event.preventDefault();
|
||||
};
|
||||
doc.addEventListener("keydown", onKeyDown);
|
||||
return () => doc.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
// A mail search asked for from outside the mail page's search box: "Find
|
||||
// emails from …" on an address in a message, "Search mail for …" on selected
|
||||
// text in the desktop app. Through a window event, like lib/app-commands,
|
||||
// since the search state lives deep inside the mail page. A request nobody
|
||||
// takes waits for the next mail page that mounts.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export type MailSearchRequest = { from: string } | { text: string };
|
||||
|
||||
const EVENT = 'bulwark:mail-search';
|
||||
let pending: MailSearchRequest | null = null;
|
||||
let listening = 0;
|
||||
|
||||
/** Whether a mail page in this window takes searches now. */
|
||||
export function mailSearchAvailable(): boolean {
|
||||
return listening > 0;
|
||||
}
|
||||
|
||||
/** Runs a search on the mail page. False when no mail page took it. */
|
||||
export function requestMailSearch(request: MailSearchRequest): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !window.dispatchEvent(new CustomEvent<MailSearchRequest>(EVENT, { detail: request, cancelable: true }));
|
||||
}
|
||||
|
||||
/** Keeps a search for the next mail page, for a caller that is about to go there. */
|
||||
export function parkMailSearch(request: MailSearchRequest): void {
|
||||
pending = request;
|
||||
}
|
||||
|
||||
/** The mail page's side: runs the searches asked for while `enabled`. */
|
||||
export function useMailSearchRequests(run: (request: MailSearchRequest) => void, enabled: boolean): void {
|
||||
const ref = useRef(run);
|
||||
useEffect(() => {
|
||||
ref.current = run;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onRequest = (event: Event) => {
|
||||
event.preventDefault();
|
||||
ref.current((event as CustomEvent<MailSearchRequest>).detail);
|
||||
};
|
||||
window.addEventListener(EVENT, onRequest);
|
||||
listening += 1;
|
||||
if (pending) {
|
||||
const request = pending;
|
||||
pending = null;
|
||||
ref.current(request);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, onRequest);
|
||||
listening -= 1;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { clearUndoHistory, undoHistory } from '@/lib/undo-history';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { movedNotice, offerMoveUndo, restoreBatchesFromNotice } from '../moved-undo';
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
const alice = { name: 'alice' };
|
||||
const bob = { name: 'bob' };
|
||||
const t = (key: string) => key;
|
||||
const POINTS = [{ id: 'm1', mailboxIds: { inbox: true }, seen: false }];
|
||||
|
||||
beforeEach(() => {
|
||||
FakeBroadcastChannel.reset();
|
||||
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
|
||||
useAuthStore.setState({
|
||||
client: alice as never,
|
||||
getAllConnectedClients: () => new Map([['alice', alice], ['bob', bob]]) as never,
|
||||
getClientForAccount: (id: string) => ({ alice, bob } as Record<string, unknown>)[id] as never,
|
||||
});
|
||||
useToastStore.getState().clearToasts();
|
||||
clearUndoHistory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState(original);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('moved-undo', () => {
|
||||
it('names each batch by its login, and another window finds its own connection for it', () => {
|
||||
const notice = movedNotice('move', [{ client: bob as never, accountId: 'shared', points: POINTS }]);
|
||||
expect(notice).toEqual({ kind: 'move', batches: [{ login: 'bob', accountId: 'shared', points: POINTS }] });
|
||||
expect(restoreBatchesFromNotice(notice)).toEqual([{ client: bob, accountId: 'shared', points: POINTS }]);
|
||||
});
|
||||
|
||||
it('with no main window to take it, the message window keeps the Undo itself', async () => {
|
||||
await offerMoveUndo('archive', alice as never, [{ client: alice as never, points: POINTS }], t, true);
|
||||
expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['email_archived']);
|
||||
expect(undoHistory()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+25
-3
@@ -14,6 +14,7 @@
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import { isAppLockedNow } from '@/lib/app-lock/state';
|
||||
import type { MessagePriority } from '@/lib/message-priority';
|
||||
import type { EmailRestorePoint } from '@/stores/email-store';
|
||||
import type { ComposePopoutPayload } from './compose-payload';
|
||||
import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed';
|
||||
|
||||
@@ -54,6 +55,16 @@ export interface PopoutSentNotice {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message a message window archived, deleted or moved, for the main window
|
||||
* to offer Undo for (a toast and Ctrl+Z). The message window has closed by then.
|
||||
*/
|
||||
export interface PopoutMovedNotice {
|
||||
kind: 'archive' | 'delete' | 'move';
|
||||
/** Where the messages were (stores/email-store.ts EmailRestoreBatch), by login instead of client. */
|
||||
batches: Array<{ login: string | null; accountId?: string; points: EmailRestorePoint[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -91,6 +102,8 @@ export type PopoutMessage =
|
||||
| { type: 'handoff-rendered'; handoffId: string }
|
||||
| { type: 'sent'; requestId: string; notice: PopoutSentNotice }
|
||||
| { type: 'sent-ack'; requestId: string }
|
||||
| { type: 'moved'; requestId: string; notice: PopoutMovedNotice }
|
||||
| { type: 'moved-ack'; requestId: string }
|
||||
| { type: 'mail-changed'; accountId: string | null }
|
||||
| { type: 'state-change' }
|
||||
| { type: 'draft-rescue'; draft: RescuedDraft }
|
||||
@@ -197,11 +210,20 @@ export async function requestWindowSession(
|
||||
|
||||
/** Tells a main window what was sent. True when one took it over. */
|
||||
export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise<boolean> {
|
||||
return announceToMainWindow((requestId) => ({ type: 'sent', requestId, notice }), 'sent-ack', timeoutMs);
|
||||
}
|
||||
|
||||
/** Hands a move's Undo to a main window. True when one took it over. */
|
||||
export function announceMovedToMainWindow(notice: PopoutMovedNotice, timeoutMs = 500): Promise<boolean> {
|
||||
return announceToMainWindow((requestId) => ({ type: 'moved', requestId, notice }), 'moved-ack', timeoutMs);
|
||||
}
|
||||
|
||||
function announceToMainWindow(message: (requestId: string) => PopoutMessage, ack: 'sent-ack' | 'moved-ack', timeoutMs: number): 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 channel = openPopoutChannel((answer) => {
|
||||
if (answer.type === ack && answer.requestId === requestId) finish(true);
|
||||
});
|
||||
const finish = (taken: boolean) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -213,7 +235,7 @@ export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 5
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
channel.post({ type: 'sent', requestId, notice });
|
||||
channel.post(message(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { toastWithUndo, undoLabels, undoMoveAction } from '@/lib/email-action-toast';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import type { EmailRestoreBatch } from '@/stores/email-store';
|
||||
import { announceMovedToMainWindow, type PopoutMovedNotice } from './channel';
|
||||
|
||||
type MoveKind = PopoutMovedNotice['kind'];
|
||||
type Translate = (key: string, values?: Record<string, string>) => string;
|
||||
|
||||
/** The toast's title for each kind, in the `notifications` namespace. */
|
||||
const TITLE_KEY: Record<MoveKind, string> = {
|
||||
archive: 'email_archived',
|
||||
delete: 'email_deleted',
|
||||
move: 'email_moved',
|
||||
};
|
||||
|
||||
/** A move this window made, with logins in place of clients, so another window can undo it. */
|
||||
export function movedNotice(kind: MoveKind, batches: EmailRestoreBatch[] | null): PopoutMovedNotice {
|
||||
const clients = [...useAuthStore.getState().getAllConnectedClients()];
|
||||
return {
|
||||
kind,
|
||||
batches: (batches ?? []).map((batch) => ({
|
||||
login: clients.find(([, client]) => client === batch.client)?.[0] ?? null,
|
||||
accountId: batch.accountId,
|
||||
points: batch.points,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A notice's restore batches, through this window's own connections. */
|
||||
export function restoreBatchesFromNotice(notice: PopoutMovedNotice): EmailRestoreBatch[] {
|
||||
const auth = useAuthStore.getState();
|
||||
return notice.batches.flatMap((batch) => {
|
||||
const client = (batch.login ? auth.getClientForAccount(batch.login) : undefined) ?? auth.client;
|
||||
return client && batch.points.length > 0 ? [{ client, accountId: batch.accountId, points: batch.points }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The toast with Undo after a message view archived, deleted or moved its
|
||||
* message. A message window closes right after, and its toast would go with
|
||||
* it: there the main window shows the toast and keeps the Undo, so Ctrl+Z in
|
||||
* the main window reaches it. With no main window to take it, this one does.
|
||||
*/
|
||||
export async function offerMoveUndo(
|
||||
kind: MoveKind,
|
||||
client: IJMAPClient | null | undefined,
|
||||
restore: EmailRestoreBatch[] | null,
|
||||
t: Translate,
|
||||
inOwnWindow: boolean,
|
||||
): Promise<void> {
|
||||
if (inOwnWindow && (await announceMovedToMainWindow(movedNotice(kind, restore)))) return;
|
||||
toastWithUndo(t(TITLE_KEY[kind]), undoMoveAction(client, restore, undoLabels(t)));
|
||||
}
|
||||
|
||||
/** The main window's side of offerMoveUndo. `t` is of the `notifications` namespace. */
|
||||
export function takeOverMoved(notice: PopoutMovedNotice, t: Translate): void {
|
||||
const batches = restoreBatchesFromNotice(notice);
|
||||
toastWithUndo(t(TITLE_KEY[notice.kind]), undoMoveAction(useAuthStore.getState().client, batches, undoLabels(t)));
|
||||
}
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "إغلاق"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "البحث في البريد عن «{text}»"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "قفل التطبيق",
|
||||
"section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Tanca"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Cerca «{text}» al correu"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueig de l'app",
|
||||
"section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Zavřít"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Hledat v poště „{text}“"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Zámek aplikace",
|
||||
"section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Luk"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Søg efter “{text}” i mail"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Applås",
|
||||
"section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Schließen"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "E-Mails nach „{text}“ durchsuchen"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App-Sperre",
|
||||
"section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Search mail for \"{text}\""
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App lock",
|
||||
"section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Cerrar"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Buscar «{text}» en el correo"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueo de la app",
|
||||
"section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Sulge"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Otsi kirjadest „{text}”"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Rakenduse lukk",
|
||||
"section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "بستن"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "جستجوی «{text}» در نامهها"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "قفل برنامه",
|
||||
"section_desc": "پس از مدتی بیاستفاده، در هر پنجره و زبانه پین میخواهد. تا وقتی قفل است، ایمیلهای این دستگاه رمزگذاریشده میمانند و برنامه کلید آنها را ندارد.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Fermer"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Rechercher « {text} » dans les messages"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Verrouillage de l'app",
|
||||
"section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.",
|
||||
|
||||
@@ -6835,6 +6835,9 @@
|
||||
"close": "סגירה"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "חיפוש “{text}” בדואר"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "נעילת אפליקציה",
|
||||
"section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Bezárás"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "„{text}” keresése a levelek között"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Alkalmazászár",
|
||||
"section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Chiudi"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Cerca «{text}» nella posta"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blocco dell'app",
|
||||
"section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "閉じる"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "メールで「{text}」を検索"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "アプリのロック",
|
||||
"section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "닫기"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "메일에서 “{text}” 검색"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "앱 잠금",
|
||||
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Aizvērt"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Meklēt pastā “{text}”"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Lietotnes bloķēšana",
|
||||
"section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Хаах"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Захидлаас “{text}”-г хайх"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Аппын түгжээ",
|
||||
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Lukk"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Søk etter «{text}» i e-post"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Applås",
|
||||
"section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Sluit"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Zoek ‘{text}’ in e-mail"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "App-vergrendeling",
|
||||
"section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Zamknij"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Szukaj „{text}” w poczcie"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blokada aplikacji",
|
||||
"section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Fechar"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Pesquisar «{text}» no correio"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Bloqueio da app",
|
||||
"section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Închidere"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Căutare „{text}” în e-mail"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Blocarea aplicației",
|
||||
"section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Найти «{text}» в почте"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Блокировка приложения",
|
||||
"section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Zavrieť"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Hľadať „{text}“ v pošte"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Zámok aplikácie",
|
||||
"section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.",
|
||||
|
||||
@@ -6842,6 +6842,9 @@
|
||||
"close": "ปิด"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "ค้นหา “{text}” ในอีเมล"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "ล็อกแอป",
|
||||
"section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Kapat"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "E-postalarda “{text}” ara"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Uygulama kilidi",
|
||||
"section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "Закрити"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Шукати «{text}» у пошті"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Блокування застосунку",
|
||||
"section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.",
|
||||
|
||||
@@ -6846,6 +6846,9 @@
|
||||
"close": "Đóng"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "Tìm “{text}” trong thư"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "Khóa ứng dụng",
|
||||
"section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "關閉"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "在郵件中搜尋「{text}」"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "應用程式鎖定",
|
||||
"section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。",
|
||||
|
||||
@@ -6834,6 +6834,9 @@
|
||||
"close": "关闭"
|
||||
}
|
||||
},
|
||||
"desktop_mail": {
|
||||
"context_search_mail": "在邮件中搜索“{text}”"
|
||||
},
|
||||
"appLock": {
|
||||
"section_title": "应用锁",
|
||||
"section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。",
|
||||
|
||||
Reference in New Issue
Block a user