feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useContactStore } from '../contact-store';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
|
||||
|
||||
const makeContact = (overrides: Partial<ContactCard> = {}): ContactCard => ({
|
||||
id: 'contact-1',
|
||||
addressBookIds: { 'ab-1': true },
|
||||
name: { components: [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }], isOrdered: true },
|
||||
emails: { e0: { address: 'john@example.com' } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeGroup = (overrides: Partial<ContactCard> = {}): ContactCard => ({
|
||||
id: 'group-1',
|
||||
addressBookIds: {},
|
||||
kind: 'group',
|
||||
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
||||
members: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultState = {
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsSync: false,
|
||||
selectedContactIds: new Set<string>(),
|
||||
activeTab: 'all' as const,
|
||||
};
|
||||
|
||||
describe('contact-store', () => {
|
||||
beforeEach(() => {
|
||||
useContactStore.setState(defaultState);
|
||||
});
|
||||
|
||||
describe('addLocalContact', () => {
|
||||
it('should append contact to array', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact());
|
||||
expect(useContactStore.getState().contacts).toHaveLength(1);
|
||||
expect(useContactStore.getState().contacts[0].id).toBe('contact-1');
|
||||
});
|
||||
|
||||
it('should preserve existing contacts', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
||||
expect(useContactStore.getState().contacts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLocalContact', () => {
|
||||
it('should update matching contact', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().updateLocalContact('c1', {
|
||||
emails: { e0: { address: 'updated@example.com' } },
|
||||
});
|
||||
expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com');
|
||||
});
|
||||
|
||||
it('should not modify other contacts', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } }));
|
||||
useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } });
|
||||
expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com');
|
||||
});
|
||||
|
||||
it('should no-op for non-existent id', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact());
|
||||
useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' });
|
||||
expect(useContactStore.getState().contacts).toHaveLength(1);
|
||||
expect(useContactStore.getState().contacts[0].kind).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLocalContact', () => {
|
||||
it('should remove contact by id', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
||||
useContactStore.getState().deleteLocalContact('c1');
|
||||
expect(useContactStore.getState().contacts).toHaveLength(1);
|
||||
expect(useContactStore.getState().contacts[0].id).toBe('c2');
|
||||
});
|
||||
|
||||
it('should clear selectedContactId when deleting selected', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().setSelectedContact('c1');
|
||||
useContactStore.getState().deleteLocalContact('c1');
|
||||
expect(useContactStore.getState().selectedContactId).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve selectedContactId when deleting other', () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
|
||||
useContactStore.getState().setSelectedContact('c1');
|
||||
useContactStore.getState().deleteLocalContact('c2');
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedContact', () => {
|
||||
it('should set selectedContactId', () => {
|
||||
useContactStore.getState().setSelectedContact('c1');
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
||||
});
|
||||
|
||||
it('should allow null to deselect', () => {
|
||||
useContactStore.getState().setSelectedContact('c1');
|
||||
useContactStore.getState().setSelectedContact(null);
|
||||
expect(useContactStore.getState().selectedContactId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSearchQuery', () => {
|
||||
it('should set search query', () => {
|
||||
useContactStore.getState().setSearchQuery('john');
|
||||
expect(useContactStore.getState().searchQuery).toBe('john');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSupportsSync', () => {
|
||||
it('should set supportsSync flag', () => {
|
||||
useContactStore.getState().setSupportsSync(true);
|
||||
expect(useContactStore.getState().supportsSync).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTab', () => {
|
||||
it('should set active tab', () => {
|
||||
useContactStore.getState().setActiveTab('groups');
|
||||
expect(useContactStore.getState().activeTab).toBe('groups');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearContacts', () => {
|
||||
it('should reset all contact-related state', () => {
|
||||
useContactStore.setState({
|
||||
contacts: [makeContact()],
|
||||
addressBooks: [{ id: 'ab-1', name: 'Default', isDefault: true }],
|
||||
selectedContactId: 'c1',
|
||||
searchQuery: 'test',
|
||||
error: 'some error',
|
||||
selectedContactIds: new Set(['c1', 'c2']),
|
||||
activeTab: 'groups',
|
||||
});
|
||||
|
||||
useContactStore.getState().clearContacts();
|
||||
const state = useContactStore.getState();
|
||||
expect(state.contacts).toEqual([]);
|
||||
expect(state.addressBooks).toEqual([]);
|
||||
expect(state.selectedContactId).toBeNull();
|
||||
expect(state.searchQuery).toBe('');
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.selectedContactIds.size).toBe(0);
|
||||
expect(state.activeTab).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleContactSelection', () => {
|
||||
it('should add id to selection', () => {
|
||||
useContactStore.getState().toggleContactSelection('c1');
|
||||
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove id when already selected', () => {
|
||||
useContactStore.getState().toggleContactSelection('c1');
|
||||
useContactStore.getState().toggleContactSelection('c1');
|
||||
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple selections independently', () => {
|
||||
useContactStore.getState().toggleContactSelection('c1');
|
||||
useContactStore.getState().toggleContactSelection('c2');
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
|
||||
useContactStore.getState().toggleContactSelection('c1');
|
||||
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
|
||||
expect(useContactStore.getState().selectedContactIds.has('c2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectAllContacts', () => {
|
||||
it('should set all provided ids', () => {
|
||||
useContactStore.getState().selectAllContacts(['c1', 'c2', 'c3']);
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(3);
|
||||
});
|
||||
|
||||
it('should replace previous selection', () => {
|
||||
useContactStore.getState().toggleContactSelection('c0');
|
||||
useContactStore.getState().selectAllContacts(['c1', 'c2']);
|
||||
expect(useContactStore.getState().selectedContactIds.has('c0')).toBe(false);
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('should empty the selection set', () => {
|
||||
useContactStore.getState().selectAllContacts(['c1', 'c2']);
|
||||
useContactStore.getState().clearSelection();
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
useContactStore.setState({
|
||||
contacts: [
|
||||
makeContact({ id: 'c1' }),
|
||||
makeContact({ id: 'c2', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
|
||||
makeContact({ id: 'c3', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.org' } } }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty for empty query', () => {
|
||||
expect(useContactStore.getState().getAutocomplete('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should match by name', () => {
|
||||
const results = useContactStore.getState().getAutocomplete('john');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].email).toBe('john@example.com');
|
||||
});
|
||||
|
||||
it('should match by email address', () => {
|
||||
const results = useContactStore.getState().getAutocomplete('test.org');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe('Bob');
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const results = useContactStore.getState().getAutocomplete('JANE');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].email).toBe('jane@example.com');
|
||||
});
|
||||
|
||||
it('should cap results at 10', () => {
|
||||
const manyContacts = Array.from({ length: 15 }, (_, i) =>
|
||||
makeContact({ id: `c${i}`, name: { components: [{ kind: 'given', value: `User${i}` }], isOrdered: true }, emails: { e0: { address: `user${i}@test.com` } } })
|
||||
);
|
||||
useContactStore.setState({ contacts: manyContacts });
|
||||
const results = useContactStore.getState().getAutocomplete('user');
|
||||
expect(results.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should expand group members when group name matches', () => {
|
||||
const member1 = makeContact({ id: 'm1', name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true }, emails: { e0: { address: 'alice@test.com' } } });
|
||||
const member2 = makeContact({ id: 'm2', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.com' } } });
|
||||
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
|
||||
useContactStore.setState({ contacts: [member1, member2, group] });
|
||||
|
||||
const results = useContactStore.getState().getAutocomplete('Team');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map(r => r.email).sort()).toEqual(['alice@test.com', 'bob@test.com']);
|
||||
});
|
||||
|
||||
it('should not include group itself in results', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
useContactStore.setState({ contacts: [group] });
|
||||
const results = useContactStore.getState().getAutocomplete('Team');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return contacts with multiple emails as separate results', () => {
|
||||
const multi = makeContact({
|
||||
id: 'multi',
|
||||
name: { components: [{ kind: 'given', value: 'Multi' }], isOrdered: true },
|
||||
emails: { e0: { address: 'a@test.com' }, e1: { address: 'b@test.com' } },
|
||||
});
|
||||
useContactStore.setState({ contacts: [multi] });
|
||||
const results = useContactStore.getState().getAutocomplete('Multi');
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroups', () => {
|
||||
it('should return only group contacts', () => {
|
||||
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
|
||||
const groups = useContactStore.getState().getGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].kind).toBe('group');
|
||||
});
|
||||
|
||||
it('should return empty when no groups', () => {
|
||||
useContactStore.setState({ contacts: [makeContact()] });
|
||||
expect(useContactStore.getState().getGroups()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIndividuals', () => {
|
||||
it('should return non-group contacts', () => {
|
||||
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
|
||||
const individuals = useContactStore.getState().getIndividuals();
|
||||
expect(individuals).toHaveLength(1);
|
||||
expect(individuals[0].kind).not.toBe('group');
|
||||
});
|
||||
|
||||
it('should include org and undefined kind', () => {
|
||||
useContactStore.setState({
|
||||
contacts: [
|
||||
makeContact({ id: 'c1' }),
|
||||
makeContact({ id: 'c2', kind: 'org' }),
|
||||
makeGroup(),
|
||||
],
|
||||
});
|
||||
expect(useContactStore.getState().getIndividuals()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupMembers', () => {
|
||||
it('should return contacts whose ids are in group members', () => {
|
||||
const m1 = makeContact({ id: 'm1' });
|
||||
const m2 = makeContact({ id: 'm2' });
|
||||
const nonMember = makeContact({ id: 'nm' });
|
||||
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
|
||||
useContactStore.setState({ contacts: [m1, m2, nonMember, group] });
|
||||
|
||||
const members = useContactStore.getState().getGroupMembers('g1');
|
||||
expect(members).toHaveLength(2);
|
||||
expect(members.map(m => m.id).sort()).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('should return empty for group with no members', () => {
|
||||
useContactStore.setState({ contacts: [makeGroup({ id: 'g1', members: {} })] });
|
||||
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty for non-existent group', () => {
|
||||
expect(useContactStore.getState().getGroupMembers('nonexistent')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match by uid as well as id', () => {
|
||||
const m1 = makeContact({ id: 'm1', uid: 'uid-m1' });
|
||||
const group = makeGroup({ id: 'g1', members: { 'uid-m1': true } });
|
||||
useContactStore.setState({ contacts: [m1, group] });
|
||||
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should exclude members with false value', () => {
|
||||
const m1 = makeContact({ id: 'm1' });
|
||||
const m2 = makeContact({ id: 'm2' });
|
||||
const group = makeGroup({ id: 'g1', members: { m1: true, m2: false } });
|
||||
useContactStore.setState({ contacts: [m1, m2, group] });
|
||||
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroup (local mode)', () => {
|
||||
it('should create group with local- prefix id', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Friends', ['c1', 'c2']);
|
||||
const contacts = useContactStore.getState().contacts;
|
||||
expect(contacts).toHaveLength(1);
|
||||
expect(contacts[0].id).toMatch(/^local-/);
|
||||
expect(contacts[0].kind).toBe('group');
|
||||
});
|
||||
|
||||
it('should set members from provided ids', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2']);
|
||||
const group = useContactStore.getState().contacts[0];
|
||||
expect(group.members).toEqual({ m1: true, m2: true });
|
||||
});
|
||||
|
||||
it('should set group name', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Work', []);
|
||||
const group = useContactStore.getState().contacts[0];
|
||||
expect(group.name?.components?.[0]?.value).toBe('Work');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateGroup (local mode)', () => {
|
||||
it('should update group name', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Old', []);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
await useContactStore.getState().updateGroup(null, groupId, 'New');
|
||||
expect(useContactStore.getState().contacts[0].name?.components?.[0]?.value).toBe('New');
|
||||
});
|
||||
|
||||
it('should preserve group members when renaming', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
await useContactStore.getState().updateGroup(null, groupId, 'Renamed');
|
||||
expect(useContactStore.getState().contacts[0].members).toEqual({ m1: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMembersToGroup (local mode)', () => {
|
||||
it('should add new members to group', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
await useContactStore.getState().addMembersToGroup(null, groupId, ['m2', 'm3']);
|
||||
const members = useContactStore.getState().contacts[0].members;
|
||||
expect(members).toEqual({ m1: true, m2: true, m3: true });
|
||||
});
|
||||
|
||||
it('should no-op for non-existent group', async () => {
|
||||
await useContactStore.getState().addMembersToGroup(null, 'nonexistent', ['m1']);
|
||||
expect(useContactStore.getState().contacts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMembersFromGroup (local mode)', () => {
|
||||
it('should remove members from group', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2', 'm3']);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
await useContactStore.getState().removeMembersFromGroup(null, groupId, ['m2']);
|
||||
const members = useContactStore.getState().contacts[0].members;
|
||||
expect(members).toEqual({ m1: true, m3: true });
|
||||
});
|
||||
|
||||
it('should no-op for group without members', async () => {
|
||||
const group = makeGroup({ id: 'g1', members: undefined });
|
||||
useContactStore.setState({ contacts: [group] });
|
||||
await useContactStore.getState().removeMembersFromGroup(null, 'g1', ['m1']);
|
||||
expect(useContactStore.getState().contacts[0].members).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGroup (local mode)', () => {
|
||||
it('should remove group from contacts', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', []);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
await useContactStore.getState().deleteGroup(null, groupId);
|
||||
expect(useContactStore.getState().contacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear selectedContactId when deleting selected group', async () => {
|
||||
await useContactStore.getState().createGroup(null, 'Team', []);
|
||||
const groupId = useContactStore.getState().contacts[0].id;
|
||||
useContactStore.getState().setSelectedContact(groupId);
|
||||
await useContactStore.getState().deleteGroup(null, groupId);
|
||||
expect(useContactStore.getState().selectedContactId).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve selectedContactId when deleting other group', async () => {
|
||||
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
|
||||
useContactStore.getState().setSelectedContact('c1');
|
||||
await useContactStore.getState().createGroup(null, 'Team', []);
|
||||
const groupId = useContactStore.getState().contacts[1].id;
|
||||
await useContactStore.getState().deleteGroup(null, groupId);
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkDeleteContacts (local mode)', () => {
|
||||
it('should remove multiple contacts', async () => {
|
||||
useContactStore.setState({
|
||||
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' }), makeContact({ id: 'c3' })],
|
||||
});
|
||||
await useContactStore.getState().bulkDeleteContacts(null, ['c1', 'c3']);
|
||||
expect(useContactStore.getState().contacts).toHaveLength(1);
|
||||
expect(useContactStore.getState().contacts[0].id).toBe('c2');
|
||||
});
|
||||
|
||||
it('should clear selection after bulk delete', async () => {
|
||||
useContactStore.setState({
|
||||
contacts: [makeContact({ id: 'c1' })],
|
||||
selectedContactIds: new Set(['c1']),
|
||||
});
|
||||
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear selectedContactId if deleted', async () => {
|
||||
useContactStore.setState({
|
||||
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' })],
|
||||
selectedContactId: 'c1',
|
||||
});
|
||||
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
|
||||
expect(useContactStore.getState().selectedContactId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkAddToGroup (local mode)', () => {
|
||||
it('should add contacts to group and clear selection', async () => {
|
||||
const m1 = makeContact({ id: 'm1' });
|
||||
const group = makeGroup({ id: 'g1', members: {} });
|
||||
useContactStore.setState({
|
||||
contacts: [m1, group],
|
||||
selectedContactIds: new Set(['m1']),
|
||||
});
|
||||
await useContactStore.getState().bulkAddToGroup(null, 'g1', ['m1']);
|
||||
expect(useContactStore.getState().contacts.find(c => c.id === 'g1')?.members).toEqual({ m1: true });
|
||||
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importContacts (local mode)', () => {
|
||||
it('should import contacts with local- prefix ids', async () => {
|
||||
const toImport = [makeContact({ id: 'orig-1' }), makeContact({ id: 'orig-2' })];
|
||||
const count = await useContactStore.getState().importContacts(null, toImport);
|
||||
expect(count).toBe(2);
|
||||
expect(useContactStore.getState().contacts).toHaveLength(2);
|
||||
expect(useContactStore.getState().contacts[0].id).toMatch(/^local-/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence/partialize', () => {
|
||||
it('should persist contacts when supportsSync is false', () => {
|
||||
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
||||
const state = { contacts: [makeContact()], supportsSync: false };
|
||||
const persisted = partialize(state);
|
||||
expect(persisted.contacts).toHaveLength(1);
|
||||
expect(persisted.supportsSync).toBe(false);
|
||||
});
|
||||
|
||||
it('should persist empty contacts array when supportsSync is true', () => {
|
||||
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
||||
const state = { contacts: [makeContact()], supportsSync: true };
|
||||
const persisted = partialize(state);
|
||||
expect(persisted.contacts).toEqual([]);
|
||||
expect(persisted.supportsSync).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useIdentityStore } from '../identity-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||
id: 'id-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
mayDelete: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('identity-store', () => {
|
||||
beforeEach(() => {
|
||||
useIdentityStore.setState({
|
||||
identities: [],
|
||||
selectedIdentityId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
subAddress: { recentTags: [], tagSuggestions: {} },
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIdentities', () => {
|
||||
it('should set identities list', () => {
|
||||
const identities = [makeIdentity(), makeIdentity({ id: 'id-2', email: 'other@test.com' })];
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should replace existing identities', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-new' })]);
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
expect(useIdentityStore.getState().identities[0].id).toBe('id-new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addIdentity', () => {
|
||||
it('should append identity to list', () => {
|
||||
useIdentityStore.getState().addIdentity(makeIdentity());
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not remove existing identities', () => {
|
||||
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-1' }));
|
||||
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2' }));
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIdentityLocal', () => {
|
||||
it('should update matching identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1', name: 'Old' })]);
|
||||
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'New' });
|
||||
expect(useIdentityStore.getState().identities[0].name).toBe('New');
|
||||
});
|
||||
|
||||
it('should not modify other identities', () => {
|
||||
useIdentityStore.getState().setIdentities([
|
||||
makeIdentity({ id: 'id-1', name: 'First' }),
|
||||
makeIdentity({ id: 'id-2', name: 'Second' }),
|
||||
]);
|
||||
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'Updated' });
|
||||
expect(useIdentityStore.getState().identities[1].name).toBe('Second');
|
||||
});
|
||||
|
||||
it('should no-op for non-existent identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().updateIdentityLocal('nonexistent', { name: 'X' });
|
||||
expect(useIdentityStore.getState().identities[0].name).toBe('Test User');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeIdentity', () => {
|
||||
it('should remove identity by id', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
|
||||
useIdentityStore.getState().removeIdentity('id-1');
|
||||
expect(useIdentityStore.getState().identities).toHaveLength(1);
|
||||
expect(useIdentityStore.getState().identities[0].id).toBe('id-2');
|
||||
});
|
||||
|
||||
it('should clear selectedIdentityId when removing selected identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().removeIdentity('id-1');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve selectedIdentityId when removing different identity', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().removeIdentity('id-2');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectIdentity', () => {
|
||||
it('should set selectedIdentityId', () => {
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
|
||||
});
|
||||
|
||||
it('should allow null to deselect', () => {
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().selectIdentity(null);
|
||||
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLoading', () => {
|
||||
it('should set loading state', () => {
|
||||
useIdentityStore.getState().setLoading(true);
|
||||
expect(useIdentityStore.getState().isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear loading state', () => {
|
||||
useIdentityStore.getState().setLoading(true);
|
||||
useIdentityStore.getState().setLoading(false);
|
||||
expect(useIdentityStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setError', () => {
|
||||
it('should set error message', () => {
|
||||
useIdentityStore.getState().setError('Something went wrong');
|
||||
expect(useIdentityStore.getState().error).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should clear error with null', () => {
|
||||
useIdentityStore.getState().setError('error');
|
||||
useIdentityStore.getState().setError(null);
|
||||
expect(useIdentityStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearIdentities', () => {
|
||||
it('should clear identities and selection', () => {
|
||||
useIdentityStore.getState().setIdentities([makeIdentity()]);
|
||||
useIdentityStore.getState().selectIdentity('id-1');
|
||||
useIdentityStore.getState().setError('old error');
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
|
||||
const state = useIdentityStore.getState();
|
||||
expect(state.identities).toEqual([]);
|
||||
expect(state.selectedIdentityId).toBeNull();
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should not clear sub-address state', () => {
|
||||
useIdentityStore.getState().addRecentTag('shopping');
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toContain('shopping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRecentTag', () => {
|
||||
it('should add tag to recent tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('shopping');
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toEqual(['shopping']);
|
||||
});
|
||||
|
||||
it('should prepend new tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('first');
|
||||
useIdentityStore.getState().addRecentTag('second');
|
||||
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('second');
|
||||
});
|
||||
|
||||
it('should deduplicate tags by moving to front', () => {
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
useIdentityStore.getState().addRecentTag('b');
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
const tags = useIdentityStore.getState().subAddress.recentTags;
|
||||
expect(tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should cap at 10 recent tags', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
useIdentityStore.getState().addRecentTag(`tag-${i}`);
|
||||
}
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toHaveLength(10);
|
||||
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('tag-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTagSuggestion', () => {
|
||||
it('should add suggestion for domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should not duplicate existing suggestion', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should cap at 5 suggestions per domain', () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', `tag-${i}`);
|
||||
}
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should keep suggestions separate per domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('a.com', 'tag-a');
|
||||
useIdentityStore.getState().addTagSuggestion('b.com', 'tag-b');
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['a.com']).toEqual(['tag-a']);
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['b.com']).toEqual(['tag-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagSuggestionsForDomain', () => {
|
||||
it('should return suggestions for known domain', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
expect(useIdentityStore.getState().getTagSuggestionsForDomain('example.com')).toEqual(['promo']);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown domain', () => {
|
||||
expect(useIdentityStore.getState().getTagSuggestionsForDomain('unknown.com')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearRecentTags', () => {
|
||||
it('should clear recent tags', () => {
|
||||
useIdentityStore.getState().addRecentTag('a');
|
||||
useIdentityStore.getState().addRecentTag('b');
|
||||
useIdentityStore.getState().clearRecentTags();
|
||||
expect(useIdentityStore.getState().subAddress.recentTags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not clear tag suggestions', () => {
|
||||
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
|
||||
useIdentityStore.getState().clearRecentTags();
|
||||
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('should only persist subAddress state', () => {
|
||||
const { partialize } = (useIdentityStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
|
||||
const fullState = {
|
||||
identities: [makeIdentity()],
|
||||
selectedIdentityId: 'id-1',
|
||||
isLoading: true,
|
||||
error: 'err',
|
||||
subAddress: { recentTags: ['a'], tagSuggestions: {} },
|
||||
};
|
||||
const persisted = partialize(fullState);
|
||||
expect(persisted).toHaveProperty('subAddress');
|
||||
expect(persisted).not.toHaveProperty('identities');
|
||||
expect(persisted).not.toHaveProperty('selectedIdentityId');
|
||||
expect(persisted).not.toHaveProperty('isLoading');
|
||||
expect(persisted).not.toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-5
@@ -4,6 +4,7 @@ import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import { useVacationStore } from './vacation-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -16,7 +17,7 @@ interface AuthState {
|
||||
identities: Identity[];
|
||||
primaryIdentity: Identity | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
|
||||
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
@@ -34,12 +35,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
|
||||
login: async (serverUrl, username, password) => {
|
||||
login: async (serverUrl, username, password, totp) => {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
// Create JMAP client
|
||||
const client = new JMAPClient(serverUrl, username, password);
|
||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||
|
||||
// Try to connect
|
||||
await client.connect();
|
||||
@@ -55,12 +57,21 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (client.supportsContacts()) {
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.setSupportsSync(true);
|
||||
contactStore.fetchAddressBooks(client).catch(() => {});
|
||||
contactStore.fetchContacts(client).catch(() => {});
|
||||
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
|
||||
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
|
||||
} else {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
// Initialize vacation responder if supported
|
||||
const vacationStore = useVacationStore.getState();
|
||||
if (client.supportsVacationResponse()) {
|
||||
vacationStore.setSupported(true);
|
||||
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
|
||||
} else {
|
||||
vacationStore.setSupported(false);
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
@@ -138,6 +149,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Clear contact store state
|
||||
useContactStore.getState().clearContacts();
|
||||
|
||||
// Clear vacation store state
|
||||
useVacationStore.getState().clearState();
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
|
||||
+217
-2
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
function getContactDisplayName(contact: ContactCard): string {
|
||||
export function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name?.components) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||
@@ -35,6 +35,9 @@ interface ContactStore {
|
||||
error: string | null;
|
||||
supportsSync: boolean;
|
||||
|
||||
selectedContactIds: Set<string>;
|
||||
activeTab: 'all' | 'groups';
|
||||
|
||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
||||
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
||||
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||
@@ -48,9 +51,27 @@ interface ContactStore {
|
||||
setSelectedContact: (id: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSupportsSync: (supports: boolean) => void;
|
||||
setActiveTab: (tab: 'all' | 'groups') => void;
|
||||
clearContacts: () => void;
|
||||
|
||||
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
|
||||
|
||||
getGroups: () => ContactCard[];
|
||||
getIndividuals: () => ContactCard[];
|
||||
getGroupMembers: (groupId: string) => ContactCard[];
|
||||
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
|
||||
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
|
||||
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
||||
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
|
||||
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
|
||||
|
||||
toggleContactSelection: (id: string) => void;
|
||||
selectAllContacts: (ids: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
||||
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
|
||||
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactStore>()(
|
||||
@@ -63,6 +84,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsSync: false,
|
||||
selectedContactIds: new Set<string>(),
|
||||
activeTab: 'all' as const,
|
||||
|
||||
fetchContacts: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -81,6 +104,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
set({ addressBooks });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch address books:', error);
|
||||
set({ error: 'Failed to fetch address books' });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -148,6 +172,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSupportsSync: (supports) => set({ supportsSync: supports }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
clearContacts: () => set({
|
||||
contacts: [],
|
||||
@@ -155,6 +180,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
error: null,
|
||||
selectedContactIds: new Set<string>(),
|
||||
activeTab: 'all',
|
||||
}),
|
||||
|
||||
getAutocomplete: (query) => {
|
||||
@@ -165,6 +192,22 @@ export const useContactStore = create<ContactStore>()(
|
||||
const results: Array<{ name: string; email: string }> = [];
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (contact.kind === 'group') {
|
||||
const groupName = getContactDisplayName(contact);
|
||||
if (groupName.toLowerCase().includes(lower)) {
|
||||
const members = get().getGroupMembers(contact.id);
|
||||
for (const member of members) {
|
||||
const memberName = getContactDisplayName(member);
|
||||
const memberEmails = member.emails ? Object.values(member.emails) : [];
|
||||
for (const emailEntry of memberEmails) {
|
||||
if (!emailEntry.address) continue;
|
||||
results.push({ name: memberName, email: emailEntry.address });
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = getContactDisplayName(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
|
||||
@@ -183,6 +226,178 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
getGroups: () => {
|
||||
return get().contacts.filter(c => c.kind === 'group');
|
||||
},
|
||||
|
||||
getIndividuals: () => {
|
||||
return get().contacts.filter(c => c.kind !== 'group');
|
||||
},
|
||||
|
||||
getGroupMembers: (groupId) => {
|
||||
const { contacts } = get();
|
||||
const group = contacts.find(c => c.id === groupId);
|
||||
if (!group?.members) return [];
|
||||
const memberIds = Object.keys(group.members).filter(k => group.members![k]);
|
||||
return contacts.filter(c => memberIds.includes(c.id) || memberIds.includes(c.uid || ''));
|
||||
},
|
||||
|
||||
createGroup: async (client, name, memberIds) => {
|
||||
const members: Record<string, boolean> = {};
|
||||
memberIds.forEach(id => { members[id] = true; });
|
||||
|
||||
const groupData: Partial<ContactCard> = {
|
||||
kind: 'group',
|
||||
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
||||
members,
|
||||
};
|
||||
|
||||
if (client && get().supportsSync) {
|
||||
const created = await client.createContact(groupData);
|
||||
set((state) => ({ contacts: [...state.contacts, created] }));
|
||||
} else {
|
||||
const localGroup: ContactCard = {
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
addressBookIds: {},
|
||||
...groupData,
|
||||
} as ContactCard;
|
||||
set((state) => ({ contacts: [...state.contacts, localGroup] }));
|
||||
}
|
||||
},
|
||||
|
||||
updateGroup: async (client, groupId, name) => {
|
||||
const updates: Partial<ContactCard> = {
|
||||
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
|
||||
};
|
||||
if (client && get().supportsSync) {
|
||||
await client.updateContact(groupId, updates);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === groupId ? { ...c, ...updates } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
addMembersToGroup: async (client, groupId, memberIds) => {
|
||||
const { contacts } = get();
|
||||
const group = contacts.find(c => c.id === groupId);
|
||||
if (!group) return;
|
||||
|
||||
const newMembers = { ...group.members };
|
||||
memberIds.forEach(id => { newMembers[id] = true; });
|
||||
|
||||
const updates: Partial<ContactCard> = { members: newMembers };
|
||||
if (client && get().supportsSync) {
|
||||
await client.updateContact(groupId, updates);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === groupId ? { ...c, members: newMembers } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeMembersFromGroup: async (client, groupId, memberIds) => {
|
||||
const { contacts } = get();
|
||||
const group = contacts.find(c => c.id === groupId);
|
||||
if (!group?.members) return;
|
||||
|
||||
const newMembers = { ...group.members };
|
||||
memberIds.forEach(id => { delete newMembers[id]; });
|
||||
|
||||
const updates: Partial<ContactCard> = { members: newMembers };
|
||||
if (client && get().supportsSync) {
|
||||
await client.updateContact(groupId, updates);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === groupId ? { ...c, members: newMembers } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteGroup: async (client, groupId) => {
|
||||
if (client && get().supportsSync) {
|
||||
await client.deleteContact(groupId);
|
||||
}
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== groupId),
|
||||
selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId,
|
||||
}));
|
||||
},
|
||||
|
||||
toggleContactSelection: (id) => set((state) => {
|
||||
const next = new Set(state.selectedContactIds);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return { selectedContactIds: next };
|
||||
}),
|
||||
|
||||
selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }),
|
||||
|
||||
clearSelection: () => set({ selectedContactIds: new Set<string>() }),
|
||||
|
||||
bulkDeleteContacts: async (client, ids) => {
|
||||
set({ error: null });
|
||||
const { supportsSync } = get();
|
||||
const deletedIds = new Set(ids);
|
||||
|
||||
if (client && supportsSync) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await client.deleteContact(id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete contact ${id}:`, error);
|
||||
deletedIds.delete(id);
|
||||
}
|
||||
}
|
||||
if (deletedIds.size < ids.length) {
|
||||
set({ error: `Failed to delete ${ids.length - deletedIds.size} contact(s)` });
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
|
||||
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
||||
selectedContactIds: new Set<string>(),
|
||||
}));
|
||||
},
|
||||
|
||||
bulkAddToGroup: async (client, groupId, contactIds) => {
|
||||
await get().addMembersToGroup(client, groupId, contactIds);
|
||||
set({ selectedContactIds: new Set<string>() });
|
||||
},
|
||||
|
||||
importContacts: async (client, contacts) => {
|
||||
const { supportsSync } = get();
|
||||
let imported = 0;
|
||||
|
||||
for (const contact of contacts) {
|
||||
try {
|
||||
if (client && supportsSync) {
|
||||
const { id: _id, ...data } = contact;
|
||||
const created = await client.createContact(data);
|
||||
set((state) => ({ contacts: [...state.contacts, created] }));
|
||||
} else {
|
||||
const localContact: ContactCard = {
|
||||
...contact,
|
||||
id: `local-${crypto.randomUUID()}`,
|
||||
};
|
||||
set((state) => ({ contacts: [...state.contacts, localContact] }));
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
console.error('Failed to import contact:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'contact-storage',
|
||||
@@ -194,5 +409,5 @@ export const useContactStore = create<ContactStore>()(
|
||||
)
|
||||
);
|
||||
|
||||
export { getContactDisplayName, getContactPrimaryEmail };
|
||||
export { getContactPrimaryEmail };
|
||||
export type { ContactName };
|
||||
|
||||
+91
-8
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||
import { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface EmailStore {
|
||||
emails: Email[];
|
||||
@@ -23,9 +24,14 @@ interface EmailStore {
|
||||
newEmailNotification: Email | null; // New email notification for toast
|
||||
|
||||
// Thread expansion state
|
||||
expandedThreadIds: Set<string>; // Which threads are expanded in the list
|
||||
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
|
||||
isLoadingThread: string | null; // Thread ID currently being loaded
|
||||
expandedThreadIds: Set<string>;
|
||||
threadEmailsCache: Map<string, Email[]>;
|
||||
isLoadingThread: string | null;
|
||||
|
||||
// Advanced search state
|
||||
searchFilters: SearchFilters;
|
||||
isAdvancedSearchOpen: boolean;
|
||||
searchAbortController: AbortController | null;
|
||||
|
||||
setEmails: (emails: Email[]) => void;
|
||||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||||
@@ -51,6 +57,10 @@ interface EmailStore {
|
||||
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
|
||||
advancedSearch: (client: JMAPClient) => Promise<void>;
|
||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||
clearSearchFilters: () => void;
|
||||
toggleAdvancedSearch: () => void;
|
||||
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
@@ -106,6 +116,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
threadEmailsCache: new Map(),
|
||||
isLoadingThread: null,
|
||||
|
||||
// Advanced search state
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
isAdvancedSearchOpen: false,
|
||||
searchAbortController: null,
|
||||
|
||||
// Spam undo cache
|
||||
spamUndoCache: new Map(),
|
||||
|
||||
@@ -222,15 +237,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
let result;
|
||||
|
||||
// Check if we're in search mode
|
||||
if (searchQuery) {
|
||||
// Load more search results (scoped to current mailbox)
|
||||
const { searchFilters } = get();
|
||||
const hasFilters = !isFilterEmpty(searchFilters);
|
||||
|
||||
if (searchQuery || hasFilters) {
|
||||
const mailboxes = get().mailboxes;
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
|
||||
if (hasFilters) {
|
||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
|
||||
} else {
|
||||
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
|
||||
}
|
||||
} else {
|
||||
// Load more from mailbox
|
||||
// Find the mailbox to get its accountId (for shared folder support)
|
||||
@@ -617,6 +638,68 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
advancedSearch: async (client) => {
|
||||
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
|
||||
|
||||
if (searchAbortController) {
|
||||
searchAbortController.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
searchAbortController: controller,
|
||||
});
|
||||
|
||||
try {
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
set({
|
||||
emails: result.emails,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false,
|
||||
searchAbortController: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||||
isLoading: false,
|
||||
emails: [],
|
||||
hasMoreEmails: false,
|
||||
totalEmails: 0,
|
||||
searchAbortController: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setSearchFilters: (filters) => {
|
||||
set((state) => ({
|
||||
searchFilters: { ...state.searchFilters, ...filters },
|
||||
}));
|
||||
},
|
||||
|
||||
clearSearchFilters: () => {
|
||||
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
|
||||
},
|
||||
|
||||
toggleAdvancedSearch: () => {
|
||||
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
|
||||
},
|
||||
|
||||
toggleStar: async (client, emailId) => {
|
||||
try {
|
||||
const email = get().emails.find(e => e.id === emailId);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { create } from 'zustand';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
interface VacationStore {
|
||||
isEnabled: boolean;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
htmlBody: string | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
isSupported: boolean;
|
||||
|
||||
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
|
||||
updateVacationResponse: (client: JMAPClient, updates: {
|
||||
isEnabled?: boolean;
|
||||
fromDate?: string | null;
|
||||
toDate?: string | null;
|
||||
subject?: string;
|
||||
textBody?: string;
|
||||
htmlBody?: string | null;
|
||||
}) => Promise<void>;
|
||||
setSupported: (supported: boolean) => void;
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
export const useVacationStore = create<VacationStore>()((set) => ({
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
isSupported: false,
|
||||
|
||||
fetchVacationResponse: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const vacation = await client.getVacationResponse();
|
||||
set({
|
||||
isEnabled: vacation.isEnabled,
|
||||
fromDate: vacation.fromDate,
|
||||
toDate: vacation.toDate,
|
||||
subject: vacation.subject || '',
|
||||
textBody: vacation.textBody || '',
|
||||
htmlBody: vacation.htmlBody,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'fetch_error',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateVacationResponse: async (client, updates) => {
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
await client.setVacationResponse(updates);
|
||||
set((state) => ({
|
||||
...state,
|
||||
...updates,
|
||||
isSaving: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
set({
|
||||
isSaving: false,
|
||||
error: error instanceof Error ? error.message : 'save_error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setSupported: (supported) => set({ isSupported: supported }),
|
||||
|
||||
clearState: () => set({
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
isSupported: false,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user