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",