diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 215ca19d7..b9eba1af4 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -15,10 +15,10 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; -import { useAccountSecurityStore } from "@/stores/account-security-store"; import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; +import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails"; import { Button } from "@/components/ui/button"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; @@ -130,17 +130,9 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const contacts = useContactStore((s) => s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; - // Aliases live on the principal, not on identities; fetch them so an - // alias-organized event is recognised as the user's own (see isOrganizer). - const accountEmails = useAccountSecurityStore((s) => s.emails); - const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal); - const principalFetchedRef = useRef(false); - useEffect(() => { - if (principalFetchedRef.current) return; - principalFetchedRef.current = true; - if (accountEmails.length > 0) return; // already loaded elsewhere - void fetchPrincipal(); - }, [accountEmails, fetchPrincipal]); + // Aliases live on the principal, not on identities; the calendar needs them + // so an alias-organized event is recognised as the user's own (see isOrganizer). + const accountEmails = useAccountPrincipalEmails(client); // The default ParticipantIdentity (draft-ietf-jmap-calendars ยง6) is the // address new invitations are organised from, so it goes first: the event diff --git a/hooks/__tests__/use-account-principal-emails.test.tsx b/hooks/__tests__/use-account-principal-emails.test.tsx new file mode 100644 index 000000000..f98c96a8e --- /dev/null +++ b/hooks/__tests__/use-account-principal-emails.test.tsx @@ -0,0 +1,56 @@ +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { useAccountPrincipalEmails } from '../use-account-principal-emails'; +import { useAccountSecurityStore } from '@/stores/account-security-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The calendar needs the principal's alias addresses to recognise an +// alias-organised event as the user's own. The JMAP client is restored +// asynchronously after a page load, so the fetch has to wait for it: firing +// on mount failed with "Not authenticated", and the once-only guard then +// never retried for the rest of the session. + +let seen: string[] = []; + +function Harness({ client }: { client: IJMAPClient | null }) { + seen = useAccountPrincipalEmails(client); + return null; +} + +const client = {} as IJMAPClient; +let fetchPrincipal: Mock<() => Promise>; + +beforeEach(() => { + seen = []; + fetchPrincipal = vi.fn<() => Promise>().mockResolvedValue(undefined); + useAccountSecurityStore.setState({ emails: [], fetchPrincipal }); +}); + +afterEach(cleanup); + +describe('useAccountPrincipalEmails', () => { + it('waits for the JMAP client instead of failing on mount', () => { + const { rerender } = render(); + expect(fetchPrincipal).not.toHaveBeenCalled(); + + rerender(); + expect(fetchPrincipal).toHaveBeenCalledTimes(1); + }); + + it('fetches once per mount, not on every render or store update', () => { + const { rerender } = render(); + rerender(); + act(() => { + useAccountSecurityStore.setState({ emails: ['me@example.com', 'alias@example.com'] }); + }); + expect(fetchPrincipal).toHaveBeenCalledTimes(1); + expect(seen).toEqual(['me@example.com', 'alias@example.com']); + }); + + it('skips the fetch when the principal is already loaded elsewhere', () => { + useAccountSecurityStore.setState({ emails: ['me@example.com'] }); + render(); + expect(fetchPrincipal).not.toHaveBeenCalled(); + expect(seen).toEqual(['me@example.com']); + }); +}); diff --git a/hooks/use-account-principal-emails.ts b/hooks/use-account-principal-emails.ts new file mode 100644 index 000000000..62a734c68 --- /dev/null +++ b/hooks/use-account-principal-emails.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAccountSecurityStore } from '@/stores/account-security-store'; + +/** + * The account's principal addresses - its primary address plus the enabled + * aliases - loaded on first use. Aliases live on the Stalwart principal, not + * on the identities, so a view that must recognise an alias-organised event + * as the user's own (the calendar's isOrganizer) needs this list. + * + * The fetch waits for the JMAP client. After a page load the client is + * restored asynchronously; asking for the principal before it exists only + * fails with "Not authenticated", and a once-only guard that fires on mount + * then blocks the retry for the rest of the session. + */ +export function useAccountPrincipalEmails(client: IJMAPClient | null): string[] { + const emails = useAccountSecurityStore((s) => s.emails); + const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal); + const fetchedRef = useRef(false); + + useEffect(() => { + if (!client || fetchedRef.current) return; + fetchedRef.current = true; + if (emails.length > 0) return; // already loaded elsewhere + void fetchPrincipal(); + }, [client, emails, fetchPrincipal]); + + return emails; +}