From 60c7efad107d6bff635ef84df4cb72340ed6e71f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:51:03 +0100 Subject: [PATCH] fix: call the default calendar and address book "Calendar" and "Contacts" everywhere, as Settings does Stalwart names them "Stalwart Calendar (
)" and "Stalwart Address Book (
)". 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. --- .../calendar/__tests__/calendar-app.test.tsx | 2 +- .../collection-default-names.test.tsx | 78 +++++++++++++++++++ components/calendar/calendar-app.tsx | 8 +- components/calendar/calendar-print.tsx | 10 ++- .../calendar/calendar-sidebar-panel.tsx | 8 +- components/calendar/calendar-toolbar.tsx | 10 ++- components/calendar/event-detail-popover.tsx | 4 +- components/calendar/event-modal.tsx | 4 +- components/calendar/ical-import-modal.tsx | 4 +- components/calendar/quick-create-popover.tsx | 4 +- components/calendar/task-list-view.tsx | 4 +- components/calendar/task-modal.tsx | 4 +- .../contacts/__tests__/contact-form.test.tsx | 4 +- .../contacts/address-book-picker-dialog.tsx | 4 +- components/contacts/contact-form.tsx | 4 +- components/contacts/contacts-app.tsx | 17 ++-- components/contacts/contacts-sidebar.tsx | 7 +- .../email/calendar-invitation-banner.tsx | 4 +- .../booking-calendar-actions.tsx | 4 +- hooks/use-collection-names.ts | 30 +++++++ lib/default-collection-name.ts | 17 ++++ 21 files changed, 196 insertions(+), 35 deletions(-) create mode 100644 components/calendar/__tests__/collection-default-names.test.tsx create mode 100644 hooks/use-collection-names.ts diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 91526ea73..f02537e85 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -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); diff --git a/components/calendar/__tests__/collection-default-names.test.tsx b/components/calendar/__tests__/collection-default-names.test.tsx new file mode 100644 index 000000000..5d6e4c1f8 --- /dev/null +++ b/components/calendar/__tests__/collection-default-names.test.tsx @@ -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 { + 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 { + 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( + {}} + />, + ); + 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( + , + ); + 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'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35f12e35f..b78d8e5bc 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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 = { diff --git a/components/calendar/calendar-print.tsx b/components/calendar/calendar-print.tsx index e47299a47..1f8758baa 100644 --- a/components/calendar/calendar-print.tsx +++ b/components/calendar/calendar-print.tsx @@ -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 = (

{props.shownCalendars.length > 0 && ( - {t("print.calendars", { names: props.shownCalendars.map((c) => c.name).join(", ") })} + {t("print.calendars", { names: props.shownCalendars.map((c) => shownName.calendar(c)).join(", ") })} )} {props.shownCalendars.length > 0 && ·} {t("print.time_zone", { zone: formatPrintTimeZone(props.timeZone, locale, props.periodDate) })} @@ -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

{t("tasks.no_tasks")}

; @@ -659,7 +662,7 @@ function TaskListPrint({ tasks, calendars, timeFormat }: { tasks: CalendarTask[] {done ? "✓" : ""}
{task.title || t("tasks.no_title")}
- {calendar &&
{calendar.name}
} + {calendar &&
{shownName.calendar(calendar)}
}
{dueLabel}
{priority ? t(priority) : ""}
@@ -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 && (<>
{t("print.meeting")}
{meeting.uri}
)} {recurrence && (<>
{t("print.repeats")}
{recurrence}
)} {alerts && (<>
{t("tasks.alert")}
{alerts}
)} - {calendar && (<>
{t("tasks.calendar")}
{calendar.name}
)} + {calendar && (<>
{t("tasks.calendar")}
{shownName.calendar(calendar)}
)} {organizer && ( <>
{t("participants.organizer")}
diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 8b14360de..4b40317b9 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -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 && } - - {cal.name} + + {shownName.calendar(cal)} {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) })} > diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index fe6471667..682519f87 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -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} /> - - {cal.name} + + {shownName.calendar(cal)} ); @@ -382,8 +384,8 @@ export function CalendarToolbar({ )} style={isVisible ? { backgroundColor: color, borderColor: color } : undefined} /> - - {cal.name} + + {shownName.calendar(cal)} ); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 57c4ea3e2..ac03f2a9e 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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 && (
- {calendar.name} + {shownName.calendar(calendar)}
)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 4532896ac..d2e7b85d6 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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) => ( ))} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 1d3a4bac9..a66c77c94 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -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) => ( ))} diff --git a/components/calendar/quick-create-popover.tsx b/components/calendar/quick-create-popover.tsx index d4ad431fe..5c94135c5 100644 --- a/components/calendar/quick-create-popover.tsx +++ b/components/calendar/quick-create-popover.tsx @@ -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) => ( - + ))} diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index e1c766d78..e5ca93b93 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -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 && ( - {cal.name} + {shownName.calendar(cal)} )} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 0c2d5a45c..f56599ac8 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -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) => ( - + ))} diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index ef207dbdc..1dc04d212 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -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(); 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)'); }); }); diff --git a/components/contacts/address-book-picker-dialog.tsx b/components/contacts/address-book-picker-dialog.tsx index 550966d23..20bdd1594 100644 --- a/components/contacts/address-book-picker-dialog.tsx +++ b/components/contacts/address-book-picker-dialog.tsx @@ -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(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.name} + {shownName.addressBook(book)} {book.isShared && book.accountName && ( {book.accountName} )} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index fac3205b0..bd3b550b3 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -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) => ( ))} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 3a3b7b004..e455409d2 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -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 = { diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 376f83547..da2421255 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -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(); @@ -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>(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) => { @@ -939,7 +942,7 @@ function AddressBookItem({ isActive={isActive} isDragOver={isDragOver} icon={} - label={book.name} + label={shownName.addressBook(book)} count={contactCount} extra={sharedOut ? : undefined} onSelect={onSelect} diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 0f29cd8b0..422ffc9ef 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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) }} /> - {cal.name} + {shownName.calendar(cal)} ))} , diff --git a/components/email/structured-mail/booking-calendar-actions.tsx b/components/email/structured-mail/booking-calendar-actions.tsx index 217031697..d39b0123f 100644 --- a/components/email/structured-mail/booking-calendar-actions.tsx +++ b/components/email/structured-mail/booking-calendar-actions.tsx @@ -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 }) { + 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]" > - {cal.name} + {shownName.calendar(cal)} ))} , diff --git a/hooks/use-collection-names.ts b/hooks/use-collection-names.ts new file mode 100644 index 000000000..455bee894 --- /dev/null +++ b/hooks/use-collection-names.ts @@ -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 (
)" 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]); +} diff --git a/lib/default-collection-name.ts b/lib/default-collection-name.ts index dd2eff62a..f79761427 100644 --- a/lib/default-collection-name.ts +++ b/lib/default-collection-name.ts @@ -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; +}