mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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.
This commit is contained in:
@@ -68,6 +68,8 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
|
||||
|
||||
render(<CalendarManagementSettings />);
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -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<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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(<ThemesSettings embedded />);
|
||||
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(<AddressBookManagementSettings />);
|
||||
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(<CalendarManagementSettings />);
|
||||
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(<CalendarManagementSettings />);
|
||||
|
||||
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(<FilesSettingsComponent />);
|
||||
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(<DownloadsSettings />);
|
||||
expect(document.body.textContent).toContain('sample_subject');
|
||||
expect(document.body.textContent).not.toContain('Benachrichtigung');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<AddressBookEditRow
|
||||
key={book.id}
|
||||
initial={book.name}
|
||||
onSave={(name) => 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 className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block" title={book.name} dir="auto">{book.name}</span>
|
||||
<span className="text-sm font-medium truncate block" title={bookName(book)} dir="auto">{bookName(book)}</span>
|
||||
</div>
|
||||
{book.isDefault && (
|
||||
<span className="flex-shrink-0 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
@@ -269,7 +273,7 @@ export function AddressBookManagementSettings() {
|
||||
actions sit behind a ⋮ there, as for calendars. */}
|
||||
{actions.length > 0 && (
|
||||
<IconButton
|
||||
label={tContacts("row_actions", { name: book.name })}
|
||||
label={tContacts("row_actions", { name: bookName(book) })}
|
||||
className="sm:hidden flex-shrink-0 text-muted-foreground"
|
||||
onClick={(e) => openContextMenuFrom(e.currentTarget, book, "end")}
|
||||
>
|
||||
@@ -436,7 +440,7 @@ export function AddressBookManagementSettings() {
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
collectionName={bookName(book)}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: calendar.name })}</h4>
|
||||
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: collectionDisplayName(calendar.name, 'calendar', tWording('default_calendar')) })}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('default_alerts_hint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
// The calendar whose CalDAV address is open under "Use in other apps".
|
||||
const [shownUrlId, setShownUrlId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
@@ -654,8 +660,9 @@ export function CalendarManagementSettings() {
|
||||
return (
|
||||
<CalendarEditForm
|
||||
key={cal.id}
|
||||
initial={{ name: cal.name, color }}
|
||||
onSave={(data) => 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() {
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium truncate block" title={cal.name} dir="auto">{cal.name}</span>
|
||||
<span className="text-sm font-medium truncate block" title={calName(cal)} dir="auto">{calName(cal)}</span>
|
||||
{(() => {
|
||||
const caldavUrl = buildCalDavUrl(cal.id);
|
||||
if (!caldavUrl) return null;
|
||||
if (shownUrlId !== cal.id) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={false}
|
||||
data-testid="calendar-use-in-other-apps"
|
||||
onClick={(e) => { e.stopPropagation(); setShownUrlId(cal.id); }}
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
|
||||
{tWording('use_in_other_apps')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Link className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
@@ -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. */}
|
||||
<IconButton
|
||||
label={t('row_actions', { name: cal.name })}
|
||||
label={t('row_actions', { name: calName(cal) })}
|
||||
className="sm:hidden flex-shrink-0 text-muted-foreground"
|
||||
onClick={(e) => 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() {
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
collectionName={calName(cal)}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-2 py-1 text-[0.625rem] font-medium text-muted-foreground border-b border-border bg-muted/50">
|
||||
<span className="flex-1 min-w-0">Name</span>
|
||||
<span className="w-14 text-end">Size</span>
|
||||
<span className="w-16 text-end">Modified</span>
|
||||
<span className="flex-1 min-w-0">{tFiles("name")}</span>
|
||||
<span className="w-14 text-end">{tFiles("size")}</span>
|
||||
<span className="w-16 text-end">{tFiles("modified")}</span>
|
||||
</div>
|
||||
{sortedFiles.map((file) => (
|
||||
<div
|
||||
|
||||
@@ -237,7 +237,8 @@ function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActiv
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{isDefault && (
|
||||
{/* The built-in theme is called Default already: a badge saying so again is noise. */}
|
||||
{isDefault && !isDefaultTheme && (
|
||||
<span className="text-[0.625rem] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">{t('default_badge')}</span>
|
||||
)}
|
||||
{isActive && <Check className="w-4 h-4 text-primary" />}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Stalwart names the calendar and address book it creates for a new account
|
||||
* "Stalwart Calendar (<address>)" and "Stalwart Address Book (<address>)"
|
||||
* (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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user