feat: delete a contact category with an undo

This commit is contained in:
Linus Rath
2026-10-31 04:41:51 +01:00
parent e988dc2928
commit 65ffef7108
4 changed files with 119 additions and 1 deletions
@@ -171,6 +171,29 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
// 1.0 refuses to destroy a book that holds cards without
// onDestroyRemoveContents (addressBookHasContents).
it('deletes a category from its contacts and puts it back on Undo', async () => {
const tag = `live-cat-${RUN}`;
const one = await createContact({
'@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat One ${RUN}` },
keywords: { [tag]: true, keep: true },
} as Partial<ContactCard>, bookId);
const two = await createContact({
'@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` },
keywords: { [tag]: true },
} as Partial<ContactCard>, bookId);
createdCards.push(one.id, two.id);
await useContactsStore.getState().fetchContacts();
const ids = await useContactsStore.getState().removeKeyword(tag);
expect(ids.sort()).toEqual([one.id, two.id].sort());
expect((await getContact(one.id))?.keywords ?? {}).toEqual({ keep: true });
expect((await getContact(two.id))?.keywords?.[tag]).toBeFalsy();
await useContactsStore.getState().addKeyword(tag, ids);
expect((await getContact(one.id))?.keywords).toEqual({ [tag]: true, keep: true });
expect((await getContact(two.id))?.keywords).toEqual({ [tag]: true });
}, 60_000);
it('deletes an address book that still holds contacts', async () => {
const book = await createAddressBook(`live-contacts-${RUN}-full`);
const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial<ContactCard>, book.id);
@@ -6,8 +6,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check, Trash2,
} from '../icons';
import { toast } from '../../stores/toast-store';
import type { RootStackParamList } from '../../navigation/types';
import type { AddressBook } from '../../api/types';
import type { ContactCategory } from '../../stores/contacts-store';
@@ -46,6 +47,8 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
const contacts = useContactsStore((s) => s.contacts);
const addressBooks = useContactsStore((s) => s.addressBooks);
const renameKeyword = useContactsStore((s) => s.renameKeyword);
const removeKeyword = useContactsStore((s) => s.removeKeyword);
const addKeyword = useContactsStore((s) => s.addKeyword);
const totalCount = React.useMemo(
() => contacts.filter((c) => !isGroup(c)).length,
@@ -150,6 +153,31 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
}
};
// A category can be put back as it was, so it goes at once with an Undo
// (webmail 1.19 useDeleteCategory).
const deleteCategory = async (keyword: string) => {
if (renameBusy) return;
setRenameBusy(true);
let ids: string[];
try {
ids = await removeKeyword(keyword);
setRenaming(null);
} catch {
toast.error(t('contacts.category_delete_failed', "Couldn't delete the category"));
return;
} finally {
setRenameBusy(false);
}
toast.success(t('contacts.category_deleted', 'Category "{name}" deleted', { name: keyword }), {
action: {
label: t('contacts.undo', 'Undo'),
onPress: () => {
void addKeyword(keyword, ids).catch(() => toast.error(t('contacts.toast.error_update', 'Failed to update contact')));
},
},
});
};
const renderBook = (book: AddressBook & { count: number }) => (
<CategoryRow
key={book.id}
@@ -271,6 +299,15 @@ export default function ContactsSidebarDrawer({ visible, onClose }: Props) {
>
<Check size={16} color={c.primary} />
</Pressable>
<Pressable
onPress={() => { void deleteCategory(kw.keyword); }}
hitSlop={6}
style={styles.renameBtn}
accessibilityRole="button"
accessibilityLabel={t('contacts.delete_category', 'Delete category')}
>
<Trash2 size={16} color={c.error} />
</Pressable>
<Pressable
onPress={() => setRenaming(null)}
hitSlop={6}
@@ -591,6 +591,32 @@ describe('contacts-store', () => {
});
});
describe('deleting a category (removeKeyword / addKeyword)', () => {
it('takes the category off every card that has it and puts it back on those on Undo', async () => {
useContactsStore.setState({
contacts: [
card('c1', { keywords: { Gone: true, Keep: true } }),
card('c2', { keywords: { Gone: true } }),
card('c3', { keywords: { Other: true } }),
],
selectedCategory: { type: 'keyword', keyword: 'Gone' },
});
mockUpdateContact.mockResolvedValue(undefined);
const ids = await useContactsStore.getState().removeKeyword('Gone');
expect(ids).toEqual(['c1', 'c2']);
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true } }, undefined);
expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: {} }, undefined);
expect(mockUpdateContact).toHaveBeenCalledTimes(2);
expect(useContactsStore.getState().selectedCategory).toEqual({ type: 'all' });
mockUpdateContact.mockClear();
await useContactsStore.getState().addKeyword('Gone', ids);
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { keywords: { Keep: true, Gone: true } }, undefined);
expect(mockUpdateContact).toHaveBeenCalledWith('c2', { keywords: { Gone: true } }, undefined);
expect(useContactsStore.getState().contacts.find((c) => c.id === 'c3')?.keywords).toEqual({ Other: true });
});
});
describe('autocomplete', () => {
it('merges contacts, groups and recent recipients without duplicates', async () => {
useContactsStore.setState({
+32
View File
@@ -132,6 +132,13 @@ export interface ContactsState {
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
addKeywordToContacts: (ids: string[], keyword: string) => Promise<void>;
renameKeyword: (oldKeyword: string, newKeyword: string) => Promise<void>;
/**
* Delete a category: take it off every contact that has it. Resolves to
* the contacts it came off, for an Undo (webmail 1.19 removeKeyword).
*/
removeKeyword: (keyword: string) => Promise<string[]>;
/** Put a category back on these contacts (the Undo of removeKeyword). */
addKeyword: (keyword: string, contactIds: string[]) => Promise<void>;
moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise<void>;
// Groups
@@ -742,6 +749,31 @@ export const useContactsStore = create<ContactsState>()(
}
},
removeKeyword: async (keyword) => {
const kw = keyword.trim();
if (!kw) return [];
const done: string[] = [];
for (const contact of get().contacts.filter((c) => c.keywords?.[kw])) {
const { [kw]: _gone, ...rest } = contact.keywords || {};
// Through the normal update, so each contact goes to its own account.
await get().updateContact(contact.id, { keywords: rest });
done.push(contact.id);
}
const category = get().selectedCategory;
if (category.type === 'keyword' && category.keyword === kw) get().setSelectedCategory({ type: 'all' });
return done;
},
addKeyword: async (keyword, contactIds) => {
const kw = keyword.trim();
if (!kw) return;
for (const id of contactIds) {
const contact = get().contacts.find((c) => c.id === id);
if (!contact || contact.keywords?.[kw]) continue;
await get().updateContact(id, { keywords: { ...(contact.keywords || {}), [kw]: true } });
}
},
moveContactsToAddressBook: async (ids, addressBookId) => {
const target = bookTarget(addressBookId);
const primaryAccountId = getContactsAccountId();