diff --git a/src/components/settings/ContactsSettings.tsx b/src/components/settings/ContactsSettings.tsx index 555887ae..c746c304 100644 --- a/src/components/settings/ContactsSettings.tsx +++ b/src/components/settings/ContactsSettings.tsx @@ -22,6 +22,7 @@ import { CONTACTS_AUTHORITY } from '../../device-sync/types'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import type { AddressBook } from '../../api/types'; +import { addressBookLabel } from '../../lib/address-book-label'; export function ContactsSettings() { const c = useColors(); @@ -261,7 +262,7 @@ export function ContactsSettings() { <> - {book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name} + {addressBookLabel(book)} {t('settings.contacts.book_count', '{count, plural, one {# contact} other {# contacts}}', { count: book.count })} diff --git a/src/components/settings/device-sync/DeviceSyncAccountRow.tsx b/src/components/settings/device-sync/DeviceSyncAccountRow.tsx index 8fb12cbb..814783cb 100644 --- a/src/components/settings/device-sync/DeviceSyncAccountRow.tsx +++ b/src/components/settings/device-sync/DeviceSyncAccountRow.tsx @@ -40,6 +40,7 @@ import { DeviceSyncCollectionsSheet } from './DeviceSyncCollectionsSheet'; import { describeSyncStatus, type StatusAction } from './status'; import { useAndroidSync } from './use-android-sync'; import { useSyncCollections } from './use-sync-collections'; +import { nameWithAccount } from '../../../lib/address-book-label'; interface Props { account: AccountEntry; @@ -280,7 +281,7 @@ export function DeviceSyncAccountRow({ account, authority }: Props) { { value: '', label: t('settings.device_sync.new_contacts_default', 'Default address book') }, ...writableSelected.map((col) => ({ value: col.key, - label: col.isPersonal ? col.name : `${col.name} (${col.accountName})`, + label: col.isPersonal ? col.name : nameWithAccount(col.name, col.accountName), })), ]; const newContactsValue = entry.newContactsAddressBook && newContactsOptions.some((o) => o.value === entry.newContactsAddressBook) diff --git a/src/device-sync/__tests__/calendar/calendars.test.ts b/src/device-sync/__tests__/calendar/calendars.test.ts index 98c82dc3..703ffe29 100644 --- a/src/device-sync/__tests__/calendar/calendars.test.ts +++ b/src/device-sync/__tests__/calendar/calendars.test.ts @@ -94,6 +94,16 @@ describe('calendar planner: calendar rows', () => { expect(values[Calendars.OWNER_ACCOUNT]).toBe(ME); }); + it('names a shared calendar once when Stalwart already put the account in its name', () => { + const plan = calendarPlanner.planCalendars( + [{ jmapAccountId: 'd', calendar: { id: 'x', name: 'Stalwart Calendar (team@example.org)', myRights: { mayWriteAll: true } } as CalendarLike, readOnly: false, accountName: 'team@example.org' }], + [], + new Harness().ctx, + ); + const values = (plan.groups[0].ops[0] as { values: Record }).values; + expect(values[Calendars.CALENDAR_DISPLAY_NAME]).toBe('Stalwart Calendar (team@example.org)'); + }); + it('deletes rows of deselected calendars and never syncs the birthday calendar', async () => { const h = await new Harness().setup(['b', 'w']); const local = await h.calendars(); diff --git a/src/device-sync/calendar/calendars.ts b/src/device-sync/calendar/calendars.ts index d1fe6302..5683cc20 100644 --- a/src/device-sync/calendar/calendars.ts +++ b/src/device-sync/calendar/calendars.ts @@ -17,6 +17,7 @@ import type { CalendarLike } from '../wire'; import { collectionKey, parseCollectionKey } from '../common/ids'; import { deepEqual, parseJsonColumn } from '../common/json'; import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; +import { nameWithAccount } from '../../lib/address-book-label'; import { colors } from '../../theme/tokens'; import { isSelfAddress } from './attendees'; import { CAN_PARTIALLY_UPDATE } from './columns'; @@ -80,7 +81,7 @@ export function calendarValues(selected: SelectedCalendar, ctx: CalendarsContext return { [Calendars._SYNC_ID]: collectionKey(selected.jmapAccountId, calendar.id), [Calendars.NAME]: name, - [Calendars.CALENDAR_DISPLAY_NAME]: shared ? `${name} (${selected.accountName})` : name, + [Calendars.CALENDAR_DISPLAY_NAME]: shared ? nameWithAccount(name, selected.accountName) : name, [Calendars.CALENDAR_COLOR]: cssColorToArgb(calendar.color) ?? cssColorToArgb(paletteColor(calendar.id))!, [Calendars.CALENDAR_ACCESS_LEVEL]: access.level, [Calendars.OWNER_ACCOUNT]: ctx.ownerAccount, diff --git a/src/lib/__tests__/address-book-label.test.ts b/src/lib/__tests__/address-book-label.test.ts index 6cce3440..909e11d3 100644 --- a/src/lib/__tests__/address-book-label.test.ts +++ b/src/lib/__tests__/address-book-label.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { addressBookLabel } from '../address-book-label'; +import { addressBookLabel, nameWithAccount } from '../address-book-label'; + +describe('nameWithAccount', () => { + it('adds the account once', () => { + expect(nameWithAccount('Stalwart Calendar (team@example.org)', 'team@example.org')).toBe('Stalwart Calendar (team@example.org)'); + expect(nameWithAccount('Team', 'team@example.org')).toBe('Team (team@example.org)'); + expect(nameWithAccount('Team', undefined)).toBe('Team'); + }); +}); describe('addressBookLabel', () => { it('names a shared book once when Stalwart already put the account in its name', () => { @@ -15,6 +23,19 @@ describe('addressBookLabel', () => { expect(addressBookLabel({ name: 'Clients', isShared: false, accountName: 'usera@example.org' })).toBe('Clients'); }); + it('is what the contacts settings list shows', () => { + // The shared book read "Stalwart Address Book (team@example.org) (team@example.org)". + const settings = fs.readFileSync(path.resolve(__dirname, '../../components/settings/ContactsSettings.tsx'), 'utf8'); + expect(settings).toMatch(/\{addressBookLabel\(book\)\}/); + expect(settings).not.toMatch(/\$\{book\.name\} \(\$\{book\.accountName\}\)/); + }); + + it('is what the device sync address book picker shows', () => { + const row = fs.readFileSync(path.resolve(__dirname, '../../components/settings/device-sync/DeviceSyncAccountRow.tsx'), 'utf8'); + expect(row).toMatch(/nameWithAccount\(col\.name, col\.accountName\)/); + expect(row).not.toMatch(/\$\{col\.name\} \(\$\{col\.accountName\}\)/); + }); + it('is what the contact form shows', () => { const form = fs.readFileSync(path.resolve(__dirname, '../../screens/ContactFormScreen.tsx'), 'utf8'); expect(form).toMatch(/\{addressBookLabel\(book\)\}/); diff --git a/src/lib/address-book-label.ts b/src/lib/address-book-label.ts index ace8430b..7818104f 100644 --- a/src/lib/address-book-label.ts +++ b/src/lib/address-book-label.ts @@ -1,13 +1,21 @@ import type { AddressBook } from '../api/types'; +/** + * A shared collection's name with the account it comes from, unless the name + * already says so. Stalwart names a group's default address book + * "Stalwart Address Book (team@example.org)" and its default calendar the + * same way, which came out as "... (team@example.org) (team@example.org)". + */ +export function nameWithAccount(name: string, accountName: string | null | undefined): string { + if (!accountName || name.includes(accountName)) return name; + return `${name} (${accountName})`; +} + /** * How an address book is named in a picker: a shared one with the account - * it comes from, unless its name already says so. Stalwart names a default - * book "Stalwart Address Book (team@example.org)", which came out as - * "... (team@example.org) (team@example.org)". Webmail contact-form.tsx. + * it comes from, unless its name already says so. Webmail contact-form.tsx. */ export function addressBookLabel(book: Pick): string { const name = book.name ?? ''; - if (!book.isShared || !book.accountName || name.includes(book.accountName)) return name; - return `${name} (${book.accountName})`; + return book.isShared ? nameWithAccount(name, book.accountName) : name; }