fix: name a shared address book or calendar once when its name already has the account

This commit is contained in:
Linus Rath
2026-10-31 14:22:16 +01:00
parent 58c8054339
commit 423e2780d5
6 changed files with 51 additions and 9 deletions
+2 -1
View File
@@ -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() {
<>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={styles.bookName} numberOfLines={1}>
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
{addressBookLabel(book)}
</Text>
<Text style={styles.bookCount}>
{t('settings.contacts.book_count', '{count, plural, one {# contact} other {# contacts}}', { count: book.count })}
@@ -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)
@@ -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<string, unknown> }).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();
+2 -1
View File
@@ -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,
+22 -1
View File
@@ -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\)\}/);
+13 -5
View File
@@ -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<AddressBook, 'name' | 'isShared' | 'accountName'>): 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;
}