mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Merge branch 'main' into ux/mail
This commit is contained in:
@@ -76,6 +76,32 @@ describe('printContact', () => {
|
||||
expect(printWindow.document.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints from a hidden frame in the page when the browser blocks the print window', async () => {
|
||||
open.mockReturnValue(null);
|
||||
await printContact({ ...base, emails: { e: { address: 'ann@example.com' } } } as ContactCard, LABELS);
|
||||
|
||||
const frame = document.querySelector('iframe[data-contact-print-frame]') as HTMLIFrameElement;
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame.srcdoc).toContain('<h1>Ann Lee</h1>');
|
||||
expect(frame.srcdoc).toContain('<dd>ann@example.com</dd>');
|
||||
vi.spyOn(frame.contentWindow!, 'focus').mockImplementation(() => {});
|
||||
const print = vi.spyOn(frame.contentWindow!, 'print').mockImplementation(() => {});
|
||||
|
||||
// The empty start page some browsers load first is not printed.
|
||||
frame.dispatchEvent(new Event('load'));
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
|
||||
frame.contentDocument!.body.innerHTML = '<h1>Ann Lee</h1>';
|
||||
frame.dispatchEvent(new Event('load'));
|
||||
expect(print).toHaveBeenCalledOnce();
|
||||
|
||||
// Gone once the print dialog closes.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout'] });
|
||||
frame.contentWindow!.dispatchEvent(new Event('afterprint'));
|
||||
vi.runAllTimers();
|
||||
expect(document.querySelector('iframe[data-contact-print-frame]')).toBeNull();
|
||||
});
|
||||
|
||||
it('prints the headings and dates in the language of the app', async () => {
|
||||
const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record<string, string>)[key] ?? key, 'de');
|
||||
await printContact({
|
||||
|
||||
@@ -84,15 +84,44 @@ export function printLabels(t: (key: string) => string, locale: string): PrintLa
|
||||
};
|
||||
}
|
||||
|
||||
const PRINT_FRAME_ATTR = "data-contact-print-frame";
|
||||
|
||||
/**
|
||||
* Prints the sheet from a hidden frame in this page, for when the browser
|
||||
* blocked the print window. The frame goes away after printing, or when the
|
||||
* next sheet is printed.
|
||||
*/
|
||||
function printInFrame(html: string): void {
|
||||
document.querySelectorAll(`iframe[${PRINT_FRAME_ATTR}]`).forEach((f) => f.remove());
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute(PRINT_FRAME_ATTR, "");
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.tabIndex = -1;
|
||||
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
|
||||
// Printed once the sheet and its photo have loaded. Some browsers report
|
||||
// the frame's empty start page first; that one is skipped.
|
||||
let printed = false;
|
||||
frame.addEventListener("load", () => {
|
||||
const frameWindow = frame.contentWindow;
|
||||
if (printed || !frameWindow || !frame.contentDocument?.querySelector("h1")) return;
|
||||
printed = true;
|
||||
frameWindow.addEventListener("afterprint", () => setTimeout(() => frame.remove(), 0), { once: true });
|
||||
frameWindow.focus();
|
||||
frameWindow.print();
|
||||
});
|
||||
frame.srcdoc = html;
|
||||
document.body.appendChild(frame);
|
||||
}
|
||||
|
||||
export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise<void> {
|
||||
// Open the window before anything is awaited: browsers allow the popup only
|
||||
// while the click that asked for it is being handled.
|
||||
// while the click that asked for it is being handled. When the popup is
|
||||
// blocked, the sheet is printed from a hidden frame instead.
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
const name = displayName || getContactDisplayName(contact);
|
||||
const photoUri = await printablePhotoUri(contact);
|
||||
if (printWindow.closed) return;
|
||||
if (printWindow?.closed) return;
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
@@ -173,6 +202,10 @@ ${rows.join("")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
if (!printWindow) {
|
||||
printInFrame(html);
|
||||
return;
|
||||
}
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.focus();
|
||||
|
||||
@@ -52,6 +52,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useDeleteCategory } from "@/hooks/use-delete-category";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
@@ -119,8 +120,6 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
shareAddressBook,
|
||||
setDefaultAddressBook,
|
||||
renameKeyword,
|
||||
removeKeyword,
|
||||
addKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
@@ -439,26 +438,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}, [movingContactIds, contacts]);
|
||||
|
||||
// Deleting a category only takes a label off contacts: no question, an Undo (R3).
|
||||
const deleteCategory = useDeleteCategory();
|
||||
const handleDeleteKeyword = useCallback(async (keyword: string) => {
|
||||
const jmapClient = supportsSync && client ? client : null;
|
||||
try {
|
||||
const ids = await removeKeyword(jmapClient, keyword);
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
|
||||
setActiveCategory("all");
|
||||
}
|
||||
toast.success(t("category_deleted", { name: keyword }), {
|
||||
action: {
|
||||
label: t("undo"),
|
||||
onClick: () => {
|
||||
void addKeyword(jmapClient, keyword, ids).catch(() => toast.error(t("toast.error_update")));
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete category:", error);
|
||||
toast.error(t("category_delete_failed"));
|
||||
if (!(await deleteCategory(keyword))) return;
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
|
||||
setActiveCategory("all");
|
||||
}
|
||||
}, [supportsSync, client, removeKeyword, addKeyword, activeCategory, t]);
|
||||
}, [deleteCategory, activeCategory]);
|
||||
|
||||
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||
if (!client && supportsSync) return;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useDeleteCategory } from "@/hooks/use-delete-category";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -76,8 +77,8 @@ export function AddressBookManagementSettings() {
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, removeAddressBook, renameKeyword, updateContact, updateLocalContact } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, removeAddressBook, renameKeyword } = useContactStore();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
@@ -154,41 +155,15 @@ export function AddressBookManagementSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Sets a category on or off for each of these contacts.
|
||||
const setCategory = async (keyword: string, ids: string[], on: boolean) => {
|
||||
for (const id of ids) {
|
||||
const contact = useContactStore.getState().contacts.find((c) => c.id === id);
|
||||
if (!contact) continue;
|
||||
const { [keyword]: _removed, ...rest } = contact.keywords || {};
|
||||
const keywords: Record<string, boolean> = on ? { ...rest, [keyword]: true } : rest;
|
||||
const saved = supportsSync && client
|
||||
? await updateContact(client, id, { keywords })
|
||||
: await updateLocalContact(id, { keywords });
|
||||
if (!saved) throw new Error("not saved");
|
||||
}
|
||||
};
|
||||
|
||||
// Deleting a category only takes it off the contacts: no confirm, and
|
||||
// Undo puts it back on the same ones (R3).
|
||||
// Deleting a category works as in the Contacts sidebar: no confirm, and
|
||||
// Undo puts it back on the same contacts (R3).
|
||||
const handleDeleteCategory = async (keyword: string) => {
|
||||
const ids = contacts.filter((c) => c.kind !== "group" && c.keywords?.[keyword]).map((c) => c.id);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await setCategory(keyword, ids, false);
|
||||
} catch {
|
||||
toast.error(tSettings("category_delete_failed"));
|
||||
return;
|
||||
await deleteCategory(keyword);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
toast.success(tSettings("category_deleted"), {
|
||||
action: {
|
||||
label: tNotifications("rule_undo"),
|
||||
onClick: () => {
|
||||
setCategory(keyword, ids, true).catch(() => toast.error(tSettings("category_delete_failed")));
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Group: personal first, then by shared account
|
||||
@@ -405,8 +380,8 @@ export function AddressBookManagementSettings() {
|
||||
onClick={() => void handleDeleteCategory(keyword)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
title={tSettings("delete_category")}
|
||||
aria-label={tSettings("delete_category")}
|
||||
title={tContacts("delete_category")}
|
||||
aria-label={tContacts("delete_category")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user