diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts index 9eb71442..1f012675 100644 --- a/src/api/__tests__/live-stalwart-contacts.test.ts +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -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, bookId); + const two = await createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', name: { full: `Live Cat Two ${RUN}` }, + keywords: { [tag]: true }, + } as Partial, 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, book.id); diff --git a/src/components/contacts/ContactsSidebarDrawer.tsx b/src/components/contacts/ContactsSidebarDrawer.tsx index 299c0bf3..6e47ecda 100644 --- a/src/components/contacts/ContactsSidebarDrawer.tsx +++ b/src/components/contacts/ContactsSidebarDrawer.tsx @@ -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 }) => ( + { void deleteCategory(kw.keyword); }} + hitSlop={6} + style={styles.renameBtn} + accessibilityRole="button" + accessibilityLabel={t('contacts.delete_category', 'Delete category')} + > + + setRenaming(null)} hitSlop={6} diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index c4b78a27..b922bd35 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -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({ diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 23d086d7..5992fcbf 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -132,6 +132,13 @@ export interface ContactsState { removeContactKeyword: (id: string, keyword: string) => Promise; addKeywordToContacts: (ids: string[], keyword: string) => Promise; renameKeyword: (oldKeyword: string, newKeyword: string) => Promise; + /** + * 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; + /** Put a category back on these contacts (the Undo of removeKeyword). */ + addKeyword: (keyword: string, contactIds: string[]) => Promise; moveContactsToAddressBook: (ids: string[], addressBookId: string) => Promise; // Groups @@ -742,6 +749,31 @@ export const useContactsStore = create()( } }, + 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();