From e403478a057ffec70d5e2a49cddc248b68e7c411 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 08:05:31 +0100
Subject: [PATCH] fix: show the default calendar and contacts by plain names,
hide CalDAV addresses until asked and translate the previews
The CalDAV fallback test now clicks "Use in other apps" first, because the
address is no longer shown on every calendar row.
---
.../calendar-management-settings.test.tsx | 2 +
.../__tests__/settings-page-wording.test.tsx | 133 ++++++++++++++++++
.../address-book-management-settings.tsx | 16 ++-
.../settings/calendar-management-settings.tsx | 37 +++--
components/settings/downloads-settings.tsx | 5 +-
components/settings/files-settings.tsx | 22 +--
components/settings/themes-settings.tsx | 3 +-
lib/default-collection-name.ts | 23 +++
lib/download-filename.ts | 4 +-
9 files changed, 219 insertions(+), 26 deletions(-)
create mode 100644 components/settings/__tests__/settings-page-wording.test.tsx
create mode 100644 lib/default-collection-name.ts
diff --git a/components/settings/__tests__/calendar-management-settings.test.tsx b/components/settings/__tests__/calendar-management-settings.test.tsx
index 4f2b4bdce..614e64976 100644
--- a/components/settings/__tests__/calendar-management-settings.test.tsx
+++ b/components/settings/__tests__/calendar-management-settings.test.tsx
@@ -68,6 +68,8 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
render();
+ // The address waits behind "Use in other apps" (usability pass 2026-11-04).
+ fireEvent.click(await screen.findByTestId('calendar-use-in-other-apps'));
const url = await screen.findByText('https://host.example/stalwart/.well-known/caldav');
// Left to right in Arabic too: the start of the address stays in view.
expect(url).toHaveAttribute('dir', 'ltr');
diff --git a/components/settings/__tests__/settings-page-wording.test.tsx b/components/settings/__tests__/settings-page-wording.test.tsx
new file mode 100644
index 000000000..069be3b8d
--- /dev/null
+++ b/components/settings/__tests__/settings-page-wording.test.tsx
@@ -0,0 +1,133 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { AddressBook, Calendar } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useContactStore } from '@/stores/contact-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { ThemesSettings } from '../themes-settings';
+import { AddressBookManagementSettings } from '../address-book-management-settings';
+import { CalendarManagementSettings } from '../calendar-management-settings';
+import { FilesSettingsComponent } from '../files-settings';
+import { DownloadsSettings } from '../downloads-settings';
+
+// Usability pass 2026-11-04, settings-11, -33 and -36: settings pages showed
+// the server's names ("Stalwart Calendar (me@example.org)"), a raw CalDAV
+// address on every calendar, English sample text in every language and a
+// "Default" badge on the theme called Default. vitest.setup.ts renders
+// strings as their raw keys.
+
+vi.mock('../settings-section', async (importOriginal) => ({
+ ...(await importOriginal()),
+ SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+
+const initial = {
+ auth: useAuthStore.getState(),
+ contacts: useContactStore.getState(),
+ calendars: useCalendarStore.getState(),
+};
+afterEach(() => {
+ useAuthStore.setState(initial.auth, true);
+ useContactStore.setState(initial.contacts, true);
+ useCalendarStore.setState(initial.calendars, true);
+ vi.restoreAllMocks();
+});
+
+describe('theme gallery', () => {
+ beforeEach(() => {
+ act(() => {
+ useThemeStore.setState({ activeThemeId: null, syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ });
+ });
+
+ it('puts no "Default" badge on the theme that is called Default', () => {
+ render();
+ const card = document.querySelector('[data-search-label="default_name"]') as HTMLElement;
+ expect(card).toBeTruthy();
+ expect(within(card).queryByText('default_badge')).toBeNull();
+ });
+});
+
+describe('server default names', () => {
+ const rights = { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true };
+
+ it('shows the address book Stalwart made as "Contacts", and a chosen name as it is', () => {
+ useAuthStore.setState({ client: { getAccountId: () => 'acc' } } as never);
+ useContactStore.setState({
+ addressBooks: [
+ { id: 'd', name: 'Stalwart Address Book (me@example.org)', isDefault: true, isShared: false, myRights: rights, shareWith: null },
+ { id: 'c', name: 'Club', isDefault: false, isShared: false, myRights: rights, shareWith: null },
+ ] as unknown as AddressBook[],
+ contacts: [],
+ supportsSync: true,
+ fetchAddressBooks: vi.fn(async () => {}),
+ } as never);
+ render();
+ expect(screen.getByText('default_address_book')).toBeInTheDocument();
+ expect(screen.getByText('Club')).toBeInTheDocument();
+ expect(screen.queryByText(/Stalwart Address Book/)).toBeNull();
+ });
+
+ it('shows the calendar Stalwart made as "Calendar" and keeps its name when saved unchanged', async () => {
+ const updateCalendar = vi.fn().mockResolvedValue(undefined);
+ useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
+ useCalendarStore.setState({
+ calendars: [{
+ id: 'cal', name: 'Stalwart Calendar (me@example.org)', color: '#3b82f6', isDefault: true, isShared: false, shareWith: null,
+ myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
+ } as unknown as Calendar],
+ icalSubscriptions: [],
+ updateCalendar,
+ } as never);
+ render();
+ expect(screen.getByText('default_calendar')).toBeInTheDocument();
+ expect(screen.queryByText(/Stalwart Calendar/)).toBeNull();
+
+ fireEvent.click(screen.getByTitle('edit'));
+ fireEvent.click(screen.getByText('save'));
+ await vi.waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
+ expect(updateCalendar.mock.calls[0][2]).toEqual({ name: 'Stalwart Calendar (me@example.org)' });
+ });
+});
+
+describe('calendar address for other apps', () => {
+ it('keeps the CalDAV address behind "Use in other apps"', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 502 }));
+ useAuthStore.setState({
+ client: { getAccountId: () => 'acct-1' }, serverUrl: 'https://host.example/', username: 'alice@example.com',
+ } as never);
+ useCalendarStore.setState({
+ calendars: [{
+ id: 'cal', name: 'Home', color: '#3b82f6', isDefault: true, isShared: false, shareWith: null,
+ myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
+ } as unknown as Calendar],
+ icalSubscriptions: [],
+ updateCalendar: vi.fn(),
+ } as never);
+ render();
+
+ const button = await screen.findByTestId('calendar-use-in-other-apps');
+ expect(button).toHaveTextContent('use_in_other_apps');
+ expect(screen.queryByText('https://host.example/.well-known/caldav')).toBeNull();
+ fireEvent.click(button);
+ expect(screen.getByText('https://host.example/.well-known/caldav')).toBeInTheDocument();
+ });
+});
+
+describe('previews in the user language', () => {
+ it('labels the Files preview through the translations', () => {
+ render();
+ for (const key of ['name', 'size', 'modified', 'preview_documents', 'preview_photos']) {
+ expect(screen.getAllByText(key).length).toBeGreaterThan(0);
+ }
+ expect(screen.queryByText('Documents')).toBeNull();
+ expect(screen.queryByText('Modified')).toBeNull();
+ });
+
+ it('builds the file name preview from a translated sample subject', () => {
+ render();
+ expect(document.body.textContent).toContain('sample_subject');
+ expect(document.body.textContent).not.toContain('Benachrichtigung');
+ });
+});
diff --git a/components/settings/address-book-management-settings.tsx b/components/settings/address-book-management-settings.tsx
index b97f5cb91..ed9a1fc61 100644
--- a/components/settings/address-book-management-settings.tsx
+++ b/components/settings/address-book-management-settings.tsx
@@ -9,6 +9,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
+import { collectionDisplayName } from "@/lib/default-collection-name";
import { IconButton } from "@/components/ui/icon-button";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
@@ -79,6 +80,8 @@ export function AddressBookManagementSettings() {
const t = useTranslations("contacts.address_books");
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
+ const tWording = useTranslations("uxs_wording");
+ const bookName = (book: AddressBook) => collectionDisplayName(book.name, "addressBook", tWording("default_address_book"));
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, removeAddressBook, renameKeyword } = useContactStore();
@@ -144,7 +147,7 @@ export function AddressBookManagementSettings() {
if (!client) return;
const ok = await confirm({
title: t("delete_title"),
- message: t("confirm_delete", { name: book.name }),
+ message: t("confirm_delete", { name: bookName(book) }),
confirmText: t("delete"),
variant: "destructive",
});
@@ -225,8 +228,9 @@ export function AddressBookManagementSettings() {
return (
handleUpdate(book, name)}
+ initial={bookName(book)}
+ // Saved unchanged, the shown name keeps the server's own.
+ onSave={(name) => handleUpdate(book, name === bookName(book) ? book.name : name)}
onCancel={() => setEditingId(null)}
isLoading={isLoading}
/>
@@ -245,7 +249,7 @@ export function AddressBookManagementSettings() {
>
- {book.name}
+ {bookName(book)}
{book.isDefault && (
@@ -269,7 +273,7 @@ export function AddressBookManagementSettings() {
actions sit behind a ⋮ there, as for calendars. */}
{actions.length > 0 && (
openContextMenuFrom(e.currentTarget, book, "end")}
>
@@ -436,7 +440,7 @@ export function AddressBookManagementSettings() {
{
diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx
index 5713ff424..472b72417 100644
--- a/components/settings/calendar-management-settings.tsx
+++ b/components/settings/calendar-management-settings.tsx
@@ -18,6 +18,7 @@ import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
+import { collectionDisplayName } from '@/lib/default-collection-name';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
@@ -198,6 +199,7 @@ export function CalendarDefaultAlertsForm({
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
+ const tWording = useTranslations('uxs_wording');
// Alerts the rows can't show are written back unchanged.
const [timed] = useState(() => alertsToRows(calendar.defaultAlertsWithTime));
const [allDay] = useState(() => alertsToRows(calendar.defaultAlertsWithoutTime));
@@ -219,7 +221,7 @@ export function CalendarDefaultAlertsForm({
return (
{sortedFiles.map((file) => (
)}
- {isDefault && (
+ {/* The built-in theme is called Default already: a badge saying so again is noise. */}
+ {isDefault && !isDefaultTheme && (
{t('default_badge')}
)}
{isActive && }
diff --git a/lib/default-collection-name.ts b/lib/default-collection-name.ts
new file mode 100644
index 000000000..dd2eff62a
--- /dev/null
+++ b/lib/default-collection-name.ts
@@ -0,0 +1,23 @@
+/**
+ * Stalwart names the calendar and address book it creates for a new account
+ * "Stalwart Calendar ()" and "Stalwart Address Book ()"
+ * (registry defaults, 0.16 and 1.0). Settings show those as plain "Calendar"
+ * and "Contacts" in the user's language, the way the sidebar shows "Sent"
+ * instead of the server's folder name. A name the user chose stays as it is.
+ */
+
+const SERVER_DEFAULT_NAMES = {
+ calendar: /^Stalwart Calendar(?: \([^()]*\))?$/,
+ addressBook: /^Stalwart Address Book(?: \([^()]*\))?$/,
+} as const;
+
+export type DefaultCollectionKind = keyof typeof SERVER_DEFAULT_NAMES;
+
+export function isServerDefaultCollectionName(name: string | undefined, kind: DefaultCollectionKind): boolean {
+ return !!name && SERVER_DEFAULT_NAMES[kind].test(name.trim());
+}
+
+/** The name to show: `plainName` for the server's own default name, else the collection's name. */
+export function collectionDisplayName(name: string, kind: DefaultCollectionKind, plainName: string): string {
+ return isServerDefaultCollectionName(name, kind) ? plainName : name;
+}
diff --git a/lib/download-filename.ts b/lib/download-filename.ts
index 9b60f0888..ea26665b1 100644
--- a/lib/download-filename.ts
+++ b/lib/download-filename.ts
@@ -269,7 +269,7 @@ export function bundleExportFilename(
}
// Build a synthetic email for previewing templates in the settings UI.
-export function buildSampleEmail(): Email {
+export function buildSampleEmail(subject = "Benachrichtigung von Ihrem Gerät"): Email {
// Use a fixed date so the preview doesn't churn as the user types.
const iso = "2026-05-22T14:05:33Z";
return {
@@ -283,7 +283,7 @@ export function buildSampleEmail(): Email {
from: [{ name: "Alice Sender", email: "alice@example.com" }],
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
cc: [],
- subject: "Benachrichtigung von Ihrem Gerät",
+ subject,
preview: "",
hasAttachment: true,
blobId: "sample-blob",