fix: call the default calendar and address book "Calendar" and "Contacts" everywhere, as Settings does

Stalwart names them "Stalwart Calendar (<address>)" and "Stalwart
Address Book (<address>)". Settings already showed plain names; the
sidebars, pickers, the event and task editors, invitations, print and
confirm texts now use the same helper. A colleague's shared default keeps
whose it is. Two test fixtures used Stalwart's default name for a long or
own book name and now use a name the user chose, since a default name is
no longer shown as is.
This commit is contained in:
Linus Rath
2026-11-04 12:51:03 +01:00
parent 68bdaaac33
commit 60c7efad10
21 changed files with 196 additions and 35 deletions
@@ -779,7 +779,7 @@ describe('CalendarApp view menu', () => {
describe('CalendarApp long names', () => {
it('shows the full name of a cut-off calendar on hover', () => {
const long = { ...calendars[0], name: 'Stalwart Calendar (usera@internal.bulwarkmail.org)' };
const long = { ...calendars[0], name: 'Quarterly planning with the regional sales team (usera@internal.bulwarkmail.org)' };
useCalendarStore.setState({ calendars: [long] } as never);
renderApp();
expect(screen.getByTestId('calendar-item').querySelector('[title]')).toHaveAttribute('title', long.name);
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import { ContactsSidebar } from '@/components/contacts/contacts-sidebar';
import { collectionLabel } from '@/lib/default-collection-name';
import type { AddressBook, Calendar } from '@/lib/jmap/types';
// next-intl is mocked globally in vitest.setup: t returns the key.
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], activeAccountId: null };
const hook = (sel?: (s: typeof state) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return { useAccountStore: hook };
});
function cal(overrides: Partial<Calendar>): Calendar {
return {
id: 'c', name: 'Cal', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayWriteOwn: true, mayShare: false, mayDelete: true },
...overrides,
} as unknown as Calendar;
}
function book(overrides: Partial<AddressBook>): AddressBook {
return {
id: 'b', name: 'Book', isDefault: false, isSubscribed: true, sortOrder: 0, shareWith: null,
myRights: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: true },
...overrides,
} as unknown as AddressBook;
}
describe('the server\'s default calendar and address book names (settings-36)', () => {
it('reads "Calendar" in the calendar sidebar, as in Settings, and keeps names the user chose', () => {
render(
<CalendarSidebarPanel
calendars={[
cal({ id: 'd', name: 'Stalwart Calendar (sam@example.org)', isDefault: true }),
cal({ id: 'w', name: 'Work' }),
]}
selectedCalendarIds={['d', 'w']}
onToggleVisibility={() => {}}
/>,
);
expect(screen.queryByText('Stalwart Calendar (sam@example.org)')).toBeNull();
expect(screen.getByText('default_calendar')).toBeInTheDocument();
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('reads "Contacts" in the contacts sidebar', () => {
render(
<ContactsSidebar
groups={[]}
individuals={[]}
addressBooks={[
book({ id: 'd', name: 'Stalwart Address Book (sam@example.org)', isDefault: true }),
book({ id: 'f', name: 'Family' }),
]}
activeCategory="all"
onSelectCategory={vi.fn()}
onCreateGroup={vi.fn()}
onCreateContact={vi.fn()}
onEditGroup={vi.fn()}
onDeleteGroup={vi.fn()}
onComposeGroup={vi.fn()}
/>,
);
expect(screen.queryByText('Stalwart Address Book (sam@example.org)')).toBeNull();
expect(screen.getAllByText('default_address_book').length).toBeGreaterThan(0);
expect(screen.getByText('Family')).toBeInTheDocument();
});
it('keeps whose a colleague\'s shared default calendar is', () => {
expect(collectionLabel({ name: 'Stalwart Calendar (bob@example.org)', isShared: true }, 'calendar', 'Calendar')).toBe('Calendar (bob@example.org)');
expect(collectionLabel({ name: 'Stalwart Calendar (sam@example.org)' }, 'calendar', 'Calendar')).toBe('Calendar');
expect(collectionLabel({ name: 'Team', isShared: true }, 'calendar', 'Calendar')).toBe('Team');
});
});
+5 -3
View File
@@ -131,6 +131,7 @@ import { useConfig } from "@/hooks/use-config";
import { CalendarPrint, type CalendarPrintContent } from "@/components/calendar/calendar-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { requestPrint, usePrintStore } from "@/stores/print-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
// `fromEditor`: asked on the way out of the event editor, which stays open
// until a scope is picked, so Cancel goes back to it with the changes.
@@ -151,6 +152,7 @@ export interface CalendarAppProps {
export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('calendar', routeSegments);
const shownName = useCollectionNames();
const router = useRouter();
const t = useTranslations("calendar");
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
@@ -2303,7 +2305,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onClearCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("clear_events"),
message: tMgmt("confirm_clear", { name: cal.name }),
message: tMgmt("confirm_clear", { name: shownName.calendar(cal) }),
variant: "destructive",
confirmText: tMgmt("clear_events"),
});
@@ -2319,7 +2321,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDeleteCalendar={client ? async (cal: Calendar) => {
const ok = await confirmAction({
title: tMgmt("delete"),
message: tMgmt("confirm_delete", { name: cal.name }),
message: tMgmt("confirm_delete", { name: shownName.calendar(cal) }),
variant: "destructive",
confirmText: tMgmt("delete"),
});
@@ -2674,7 +2676,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<ShareCollectionDialog
client={client}
kind="calendar"
collectionName={cal.name}
collectionName={shownName.calendar(cal)}
shareWith={cal.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
+7 -3
View File
@@ -46,6 +46,7 @@ import {
type AgendaDay,
} from "@/lib/calendar-print";
import type { TaskViewFilter } from "@/stores/task-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
type TimeFormat = "12h" | "24h";
@@ -94,6 +95,7 @@ function useTimeFormatter(timeFormat: TimeFormat) {
/** The calendar page's print copy. */
export function CalendarPrint(props: CalendarPrintProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const locale = useLocale();
const { formatMonthYear, formatWeekRange, formatFullDate } = useCalendarLocale();
@@ -118,7 +120,7 @@ export function CalendarPrint(props: CalendarPrintProps) {
const meta = (
<p className="pr-meta">
{props.shownCalendars.length > 0 && (
<span>{t("print.calendars", { names: props.shownCalendars.map((c) => c.name).join(", ") })}</span>
<span>{t("print.calendars", { names: props.shownCalendars.map((c) => shownName.calendar(c)).join(", ") })}</span>
)}
{props.shownCalendars.length > 0 && <span className="pr-meta-sep" aria-hidden>·</span>}
<span>{t("print.time_zone", { zone: formatPrintTimeZone(props.timeZone, locale, props.periodDate) })}</span>
@@ -632,6 +634,7 @@ function priorityKey(priority: number): string | null {
}
function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[]; calendars: Calendar[]; timeFormat: TimeFormat }) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
if (tasks.length === 0) return <p className="pr-empty">{t("tasks.no_tasks")}</p>;
@@ -659,7 +662,7 @@ function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[]
<span className="pr-checkbox" aria-hidden>{done ? "✓" : ""}</span>
<div>
<div className="pr-task-title"><bdi>{task.title || t("tasks.no_title")}</bdi></div>
{calendar && <div className="pr-task-meta"><bdi>{calendar.name}</bdi></div>}
{calendar && <div className="pr-task-meta"><bdi>{shownName.calendar(calendar)}</bdi></div>}
</div>
<div className="pr-task-meta">{dueLabel}</div>
<div className="pr-task-meta">{priority ? t(priority) : ""}</div>
@@ -680,6 +683,7 @@ export function EventPrintSheet({
timeFormat: TimeFormat;
timeZone: string;
}) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const locale = useLocale();
const formatEventDate = useFormatEventDate();
@@ -730,7 +734,7 @@ export function EventPrintSheet({
{meeting && (<><dt>{t("print.meeting")}</dt><dd><bdi>{meeting.uri}</bdi></dd></>)}
{recurrence && (<><dt>{t("print.repeats")}</dt><dd>{recurrence}</dd></>)}
{alerts && (<><dt>{t("tasks.alert")}</dt><dd>{alerts}</dd></>)}
{calendar && (<><dt>{t("tasks.calendar")}</dt><dd><bdi>{calendar.name}</bdi></dd></>)}
{calendar && (<><dt>{t("tasks.calendar")}</dt><dd><bdi>{shownName.calendar(calendar)}</bdi></dd></>)}
{organizer && (
<>
<dt>{t("participants.organizer")}</dt>
@@ -24,6 +24,7 @@ import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
/**
* Split a per-account calendar list into "owned" (the user's own) and
@@ -92,6 +93,7 @@ export function CalendarSidebarPanel({
client,
multiAccountMode,
}: CalendarSidebarPanelProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tSub = useTranslations("calendar.subscription");
@@ -321,8 +323,8 @@ export function CalendarSidebarPanel({
>
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
</span>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
{isSubscriptionCalendar(cal.id) && (
<>
@@ -355,7 +357,7 @@ export function CalendarSidebarPanel({
"opacity-0 group-hover/cal:opacity-100 group-focus-within/cal:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
label={t('calendar_options', { name: cal.name })}
label={t('calendar_options', { name: shownName.calendar(cal) })}
>
<MoreVertical />
</IconButton>
+6 -4
View File
@@ -16,6 +16,7 @@ import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { displayNow } from "@/lib/timezone";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -105,6 +106,7 @@ export function CalendarToolbar({
onCloseSearch,
panelToggle,
}: CalendarToolbarProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -342,8 +344,8 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
</button>
);
@@ -382,8 +384,8 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={shownName.calendar(cal)}>
{shownName.calendar(cal)}
</span>
</button>
);
+3 -1
View File
@@ -36,6 +36,7 @@ import { isEditableEventTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -144,6 +145,7 @@ export function EventDetailPopover({
isMobile,
pinned = false,
}: EventDetailPopoverProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -627,7 +629,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
<span dir="auto" className="text-sm text-foreground truncate" title={shownName.calendar(calendar)}>{shownName.calendar(calendar)}</span>
</div>
)}
+3 -1
View File
@@ -56,6 +56,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { useCollectionNames } from "@/hooks/use-collection-names";
export interface PendingEventPreview {
start: Date;
@@ -273,6 +274,7 @@ export function EventModal({
maxParticipants = null,
isMobile = false,
}: EventModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
// Ids that tie each visible label to its field.
@@ -1483,7 +1485,7 @@ export function EventModal({
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
{shownName.calendar(cal)}
</option>
))}
</select>
+3 -1
View File
@@ -17,6 +17,7 @@ import { apiFetch } from "@/lib/browser-navigation";
import { explainServerAuthError } from "@/lib/server-auth-status";
import { IS_LITE } from "@/lib/lite";
import { isOfflineNow } from "@/lib/offline/state";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface ICalImportModalProps {
calendars: Calendar[];
@@ -34,6 +35,7 @@ type ImportStep = "select" | "preview" | "importing";
type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar.import");
const tLaunch = useTranslations("pwaLaunch");
const tCal = useTranslations("calendar");
@@ -465,7 +467,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl, initia
>
{calendars.map((cal) => (
<option key={cal.id} value={cal.id}>
{cal.name}
{shownName.calendar(cal)}
</option>
))}
</select>
+3 -1
View File
@@ -11,6 +11,7 @@ import { IconButton } from "@/components/ui/icon-button";
import type { Calendar } from "@/lib/jmap/types";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
export interface QuickCreateDraft {
start: Date;
@@ -66,6 +67,7 @@ export function QuickCreatePopover({
onMoreOptions,
onClose,
}: QuickCreatePopoverProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const tCommon = useTranslations("common");
@@ -201,7 +203,7 @@ export function QuickCreatePopover({
className="flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-hidden"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
<option key={c.id} value={c.id}>{shownName.calendar(c)}</option>
))}
</select>
</div>
+3 -1
View File
@@ -11,6 +11,7 @@ import type { TaskViewFilter } from "@/stores/task-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface TaskListViewProps {
tasks: CalendarTask[];
@@ -125,6 +126,7 @@ export function TaskListView({
selectedTaskId,
onQuickCreate,
}: TaskListViewProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const paint = useCvdColor();
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -252,7 +254,7 @@ export function TaskListView({
{cal && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: paint(cal.color || "#3b82f6") }} />
{cal.name}
{shownName.calendar(cal)}
</span>
)}
</span>
+3 -1
View File
@@ -16,6 +16,7 @@ import { taskToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { canCreateEventsIn } from "@/lib/calendar-editability";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -60,6 +61,7 @@ export function TaskModal({
isMobile = false,
isSubscriptionCalendar,
}: TaskModalProps) {
const shownName = useCollectionNames();
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
// The header's close goes to its start when the window's own close is at its end.
@@ -349,7 +351,7 @@ export function TaskModal({
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
<option key={cal.id} value={cal.id}>{shownName.calendar(cal)}</option>
))}
</select>
</div>
@@ -570,12 +570,12 @@ describe('ContactForm', () => {
it("names the account of an address book once, not twice", () => {
const books = [
{ id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
{ id: 'b1', name: 'Personal (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
{ id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true },
] as never;
render(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
const options = screen.getAllByRole('option').map((o) => o.textContent);
expect(options).toContain('Stalwart Address Book (usera@example.org)');
expect(options).toContain('Personal (usera@example.org)');
expect(options).toContain('Team (userb@example.org)');
});
});
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { AddressBook } from "@/lib/jmap/types";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface AddressBookPickerDialogProps {
isOpen: boolean;
@@ -21,6 +22,7 @@ interface AddressBookPickerDialogProps {
/** "Move to…" for contacts: pick the address book they go to. */
export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, currentId, count, onPick }: AddressBookPickerDialogProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const [selected, setSelected] = useState<string | null>(null);
const choice = addressBooks.find((b) => b.id === selected && b.id !== currentId) ?? null;
@@ -64,7 +66,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
)}
>
<Book className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="truncate flex-1" title={book.name}>{book.name}</span>
<span className="truncate flex-1" title={shownName.addressBook(book)}>{shownName.addressBook(book)}</span>
{book.isShared && book.accountName && (
<span className="text-xs text-muted-foreground truncate max-w-[40%]" title={book.accountName}>{book.accountName}</span>
)}
+3 -1
View File
@@ -13,6 +13,7 @@ import type { ContactSaveOptions } from "@/lib/contact-media";
import { cn } from "@/lib/utils";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
import { useCollectionNames } from "@/hooks/use-collection-names";
interface EmailEntry {
address: string;
@@ -219,6 +220,7 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
}
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts.form");
const isEditing = !!contact;
// The header's close goes to its start when the window's own close is at its end.
@@ -795,7 +797,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{/* A shared book says whose it is, unless its name already does. */}
{book.isShared && book.accountName && !book.name.includes(book.accountName) ? `${book.name} (${book.accountName})` : book.name}
{book.isShared && book.accountName && !shownName.addressBook(book).includes(book.accountName) ? `${shownName.addressBook(book)} (${book.accountName})` : shownName.addressBook(book)}
</option>
))}
</select>
+10 -7
View File
@@ -76,6 +76,7 @@ import {
DirectoryEntryPrintSheet,
DirectoryListPrint,
} from "@/components/contacts/contact-print-views";
import { useCollectionNames } from "@/hooks/use-collection-names";
type View =
| "list"
@@ -96,6 +97,7 @@ export interface ContactsAppProps {
function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const skeleton = useSkeletonLabels();
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
@@ -477,14 +479,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
try {
await moveContactToAddressBook(client, contactIds, addressBook);
const msg = contactIds.length === 1
? t("address_books.moved", { name: addressBook.name })
: t("address_books.moved_plural", { count: contactIds.length, name: addressBook.name });
? t("address_books.moved", { name: shownName.addressBook(addressBook) })
: t("address_books.moved_plural", { count: contactIds.length, name: shownName.addressBook(addressBook) });
toast.success(msg);
} catch (error) {
console.error('Failed to move contacts:', error);
toast.error(t("address_books.move_failed"));
}
}, [client, moveContactToAddressBook, t]);
}, [client, moveContactToAddressBook, t, shownName]);
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
@@ -1320,7 +1322,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
const ok = await confirmDialog({
title: t("address_books.delete_title"),
message: t("address_books.confirm_delete", { name: book.name }),
message: t("address_books.confirm_delete", { name: shownName.addressBook(book) }),
variant: "destructive",
confirmText: t("address_books.delete"),
});
@@ -1568,12 +1570,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onClose={() => setRenamingAddressBook(null)}
title={t("address_books.rename")}
placeholder={t("address_books.name_label")}
defaultValue={renamingAddressBook?.name ?? ""}
defaultValue={renamingAddressBook ? shownName.addressBook(renamingAddressBook) : ""}
confirmText={t("form.save")}
cancelText={t("form.cancel")}
onSubmit={(newName) => {
const book = renamingAddressBook;
if (!client || !book || newName === book.name) return;
// The shown name kept as it was is no rename, also for the server's default name.
if (!client || !book || newName === book.name || newName === shownName.addressBook(book)) return;
void (async () => {
try {
await renameAddressBook(client, book, newName);
@@ -1618,7 +1621,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<ShareCollectionDialog
client={client}
kind="addressBook"
collectionName={book.name}
collectionName={shownName.addressBook(book)}
shareWith={book.shareWith}
ownAccountId={client.getAccountId()}
onShare={async (principalId, rights) => {
+5 -2
View File
@@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
import { useCollectionNames } from "@/hooks/use-collection-names";
export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -116,6 +117,7 @@ export function ContactsSidebar({
className,
multiAccountMode,
}: ContactsSidebarProps) {
const shownName = useCollectionNames();
const t = useTranslations("contacts");
const router = useRouter();
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, openContextMenuFrom: openGroupMenuFrom, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
@@ -127,7 +129,7 @@ export function ContactsSidebar({
const bookMenu = (book: AddressBook) => hasBookMenu ? {
onContextMenu: (e: React.MouseEvent) => openBookContextMenu(e, book),
onOpenMenu: (anchor: HTMLElement) => openBookMenuFrom(anchor, book, "end"),
menuLabel: t("row_actions", { name: book.name }),
menuLabel: t("row_actions", { name: shownName.addressBook(book) }),
} : {};
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
@@ -905,6 +907,7 @@ function AddressBookItem({
onSelect: () => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
}) {
const shownName = useCollectionNames();
const [isDragOver, setIsDragOver] = useState(false);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
@@ -939,7 +942,7 @@ function AddressBookItem({
isActive={isActive}
isDragOver={isDragOver}
icon={<Book className="w-4 h-4 flex-shrink-0" />}
label={book.name}
label={shownName.addressBook(book)}
count={contactCount}
extra={sharedOut ? <Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" /> : undefined}
onSelect={onSelect}
@@ -51,6 +51,7 @@ import { sanitizeColor } from '@/components/calendar/event-card';
import { RecipientPopover } from './recipient-popover';
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
import { useAccountOwner } from '@/components/calendar/answer-bars';
import { useCollectionNames } from "@/hooks/use-collection-names";
interface InvitationChangeItem {
label: string;
@@ -377,6 +378,7 @@ interface CalendarInvitationBannerProps {
type BannerState = 'loading' | 'parsed' | 'rsvp-sent' | 'imported' | 'error';
export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProps) {
const shownName = useCollectionNames();
const t = useTranslations('email_viewer.calendar_invitation');
const describeError = useCalendarErrorMessage();
const format = useFormatter();
@@ -1236,7 +1238,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: sanitizeColor(cal.color) }}
/>
<span className="truncate text-foreground">{cal.name}</span>
<span className="truncate text-foreground">{shownName.calendar(cal)}</span>
</button>
))}
</div>,
@@ -17,6 +17,7 @@ import { isDocumentRTL } from '@/i18n/direction';
import type { Email } from '@/lib/jmap/types';
import type { StructuredCard } from '@/lib/structured-mail';
import { useBookingCalendar } from './use-booking-calendar';
import { useCollectionNames } from "@/hooks/use-collection-names";
const BUTTON = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50';
const PICKER_WIDTH = 208;
@@ -47,6 +48,7 @@ function pickerPosition(rect: DOMRect, count: number): PickerPosition {
* action (see useBookingCalendar).
*/
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
const shownName = useCollectionNames();
const t = useTranslations('structured_mail_calendar');
const calendar = useBookingCalendar(booking, email);
const { status, calendars, busy, error, notice } = calendar;
@@ -186,7 +188,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none flex items-center gap-2 min-h-[36px]"
>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: sanitizeColor(cal.color) }} />
<span className="truncate text-foreground">{cal.name}</span>
<span className="truncate text-foreground">{shownName.calendar(cal)}</span>
</button>
))}
</div>,
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { collectionLabel } from "@/lib/default-collection-name";
interface NamedCollection {
name: string;
isShared?: boolean;
}
/**
* Names for calendars and address books as Settings shows them: Stalwart's own
* "Stalwart Calendar (<address>)" becomes "Calendar", in the user's language.
* Use it wherever a calendar or address book name reaches the screen.
*/
export function useCollectionNames(): {
calendar: (calendar: NamedCollection) => string;
addressBook: (book: NamedCollection) => string;
} {
const t = useTranslations("uxs_wording");
return useMemo(() => {
const calendarName = t("default_calendar");
const bookName = t("default_address_book");
return {
calendar: (calendar: NamedCollection) => collectionLabel(calendar, "calendar", calendarName),
addressBook: (book: NamedCollection) => collectionLabel(book, "addressBook", bookName),
};
}, [t]);
}
+17
View File
@@ -21,3 +21,20 @@ export function isServerDefaultCollectionName(name: string | undefined, kind: De
export function collectionDisplayName(name: string, kind: DefaultCollectionKind, plainName: string): string {
return isServerDefaultCollectionName(name, kind) ? plainName : name;
}
/**
* The name to show for a collection anywhere in the app: the plain name for the
* user's own default one, and for a colleague's shared default the plain name with
* whose it is ("Calendar (bob@example.org)"), so two of them stay apart.
*/
export function collectionLabel(
collection: { name: string; isShared?: boolean },
kind: DefaultCollectionKind,
plainName: string,
): string {
const { name } = collection;
if (!isServerDefaultCollectionName(name, kind)) return name;
if (!collection.isShared) return plainName;
const owner = /\(([^()]*)\)\s*$/.exec(name.trim())?.[1];
return owner ? `${plainName} (${owner})` : plainName;
}