mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user