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 (
-

{t('default_alerts_title', { name: calendar.name })}

+

{t('default_alerts_title', { name: collectionDisplayName(calendar.name, 'calendar', tWording('default_calendar')) })}

{t('default_alerts_hint')}

@@ -269,6 +271,8 @@ export function CalendarDefaultAlertsForm({ export function CalendarManagementSettings() { const t = useTranslations('calendar.management'); + const tWording = useTranslations('uxs_wording'); + const calName = (cal: { name: string }) => collectionDisplayName(cal.name, 'calendar', tWording('default_calendar')); const { client, serverUrl, username } = useAuthStore(); const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const { calendars, updateCalendar, reorderCalendars, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore(); @@ -290,6 +294,8 @@ export function CalendarManagementSettings() { const [clearingId, setClearingId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [colorPickerId, setColorPickerId] = useState(null); + // The calendar whose CalDAV address is open under "Use in other apps". + const [shownUrlId, setShownUrlId] = useState(null); const [sharingId, setSharingId] = useState(null); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); @@ -654,8 +660,9 @@ export function CalendarManagementSettings() { return ( handleUpdate(cal.id, data, color)} + initial={{ name: calName(cal), color }} + // Saved unchanged, the shown name keeps the server's own. + onSave={(data) => handleUpdate(cal.id, { ...data, name: data.name === calName(cal) ? cal.name : data.name }, color)} onCancel={() => setEditingId(null)} isLoading={isLoading} /> @@ -735,10 +742,24 @@ export function CalendarManagementSettings() {
- {cal.name} + {calName(cal)} {(() => { const caldavUrl = buildCalDavUrl(cal.id); if (!caldavUrl) return null; + if (shownUrlId !== cal.id) { + return ( + + ); + } return (
@@ -791,7 +812,7 @@ export function CalendarManagementSettings() { and pushed the Default badge over the copy button, so the row actions sit behind a ⋮ there, as for folders. */} openContextMenuFrom(e.currentTarget, cal, 'end')} > @@ -943,7 +964,7 @@ export function CalendarManagementSettings() { onClose={() => setDeletingId(null)} onConfirm={() => { if (deletingId) void handleDelete(deletingId); }} title={tConfirm('delete_title')} - message={t('confirm_delete', { name: calendars.find((cal) => cal.id === deletingId)?.name ?? '' })} + message={t('confirm_delete', { name: calName(calendars.find((cal) => cal.id === deletingId) ?? { name: '' }) })} confirmText={t('delete')} variant="destructive" /> @@ -952,7 +973,7 @@ export function CalendarManagementSettings() { onClose={() => setClearingId(null)} onConfirm={() => { if (clearingId) void handleClear(clearingId); }} title={tConfirm('clear_title')} - message={t('confirm_clear', { name: calendars.find((cal) => cal.id === clearingId)?.name ?? '' })} + message={t('confirm_clear', { name: calName(calendars.find((cal) => cal.id === clearingId) ?? { name: '' }) })} confirmText={t('clear_events')} variant="destructive" /> @@ -996,7 +1017,7 @@ export function CalendarManagementSettings() { { diff --git a/components/settings/downloads-settings.tsx b/components/settings/downloads-settings.tsx index b134869ad..9c334a905 100644 --- a/components/settings/downloads-settings.tsx +++ b/components/settings/downloads-settings.tsx @@ -147,7 +147,10 @@ export function DownloadsSettings() { // With the Archive action off a stored "archive" keeps the message, so it shows as "keep". const postExportShown = archiveActionEnabled || postExportAction !== "archive" ? postExportAction : "keep"; - const sampleEmail = useMemo(() => buildSampleEmail(), []); + const tWording = useTranslations("uxs_wording"); + // A subject in the user's language, with a letter or two the "plain letters" option changes. + const sampleSubject = tWording("sample_subject"); + const sampleEmail = useMemo(() => buildSampleEmail(sampleSubject), [sampleSubject]); const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []); const transform = useMemo( diff --git a/components/settings/files-settings.tsx b/components/settings/files-settings.tsx index 15677473f..db7644a03 100644 --- a/components/settings/files-settings.tsx +++ b/components/settings/files-settings.tsx @@ -11,6 +11,8 @@ import { useConfig } from "@/hooks/use-config"; interface SampleFile { name: string; + /** Folder names are translated; file names stay as they are. */ + labelKey?: "preview_documents" | "preview_photos"; isFolder: boolean; size: number; modified: string; @@ -19,11 +21,11 @@ interface SampleFile { } const SAMPLE_FILES: SampleFile[] = [ - { name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" }, - { name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" }, + { name: "Documents", labelKey: "preview_documents", isFolder: true, size: 0, modified: "2026-03-10" }, + { name: "Photos", labelKey: "preview_photos", isFolder: true, size: 0, modified: "2026-03-14" }, { name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" }, { name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" }, - { name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" }, + { name: "logo.png", isFolder: false, size: 34000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" }, { name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" }, { name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true }, ]; @@ -58,12 +60,16 @@ function formatSize(bytes: number): string { } function FilesSettingsPreview({ settings }: { settings: FilesSettings }) { + const tFiles = useTranslations("files"); + const tWording = useTranslations("uxs_wording"); // The sample thumbnail shows the configured logo on a branded deployment, // the bundled one otherwise. (#1160) const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const thumbnailUrl = appLogoLightUrl || appLogoDarkUrl || "/branding/Bulwark_Logo_Color.png"; const sortedFiles = useMemo(() => { - let files = SAMPLE_FILES.map((f) => (f.thumbnailUrl ? { ...f, thumbnailUrl } : f)).filter((f) => { + let files = SAMPLE_FILES + .map((f) => (f.labelKey ? { ...f, name: tWording(f.labelKey) } : f)) + .map((f) => (f.thumbnailUrl ? { ...f, thumbnailUrl } : f)).filter((f) => { if (!settings.showHiddenFiles && f.hidden) return false; return true; }); @@ -82,14 +88,14 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) { }); return files; - }, [settings.showHiddenFiles, settings.defaultSortKey, settings.defaultSortDir, thumbnailUrl]); + }, [settings.showHiddenFiles, settings.defaultSortKey, settings.defaultSortDir, thumbnailUrl, tWording]); const listView = (
- Name - Size - Modified + {tFiles("name")} + {tFiles("size")} + {tFiles("modified")}
{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",