diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 7930c9ac7..0714adb1c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -58,6 +58,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; +import { useEventUserEmails } from "@/hooks/use-event-user-emails"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { descriptionWithNote } from "@/lib/calendar-description"; @@ -225,6 +226,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ), [participantIdentities, identities, accountEmails] ); + // An event of another signed-in login is answered as that login (pim-12). + const userEmailsFor = useEventUserEmails(currentUserEmails); const resolveContactName = useContactNameResolver(); const [showEventModal, setShowEventModal] = useState(false); @@ -570,12 +573,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [client, calendars.length, dateRange, fetchEvents, isEmbedded, proInterfaceActive]); - // Pro shell only: aggregate calendars and events from every connected - // account so the sidebar lists them all (and the views render their - // events together). The hook is a no-op outside the embedded shell. + // Aggregate calendars and events from every connected account so the + // sidebar lists them all (and the views render their events together): + // in the Pro shell, and with two or more signed-in logins (pim-12). The + // standard instance behind the Pro shell leaves the loading to the shell. const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars( - isEmbedded ? dateRange?.start ?? null : null, - isEmbedded ? dateRange?.end ?? null : null, + isEmbedded || !proInterfaceActive ? dateRange?.start ?? null : null, + isEmbedded || !proInterfaceActive ? dateRange?.end ?? null : null, ); const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); @@ -1606,11 +1610,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (!detailEvent || !client) return; // A target names the event and participant: the owner's in a calendar // shared with the user, or the user's own copy (lib/calendar-delegate.ts). - const participantId = target?.participantId ?? getUserParticipantId(detailEvent, currentUserEmails); + const participantId = target?.participantId ?? getUserParticipantId(detailEvent, userEmailsFor(detailEvent)); if (!participantId) return; closeDetail(); await handleRsvp(target?.eventId ?? detailEvent.id, participantId, status, target?.sentBy); - }, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]); + }, [detailEvent, client, userEmailsFor, closeDetail, handleRsvp]); // `/` and the palette's "Search events" go to the search box. const openSearch = useCallback(() => { @@ -2379,7 +2383,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onDirtyChange={setEventEditorDirty} onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(editEvent)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={false} @@ -2516,7 +2520,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onRsvp={handleRsvpFromDetail} onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }} onMouseLeave={handleHoverLeave} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(detailEvent)} isSubscriptionCalendar={isSubscriptionCalendar} timeFormat={timeFormat} isMobile={isMobile} @@ -2542,7 +2546,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(editEvent)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={true} diff --git a/hooks/use-event-user-emails.ts b/hooks/use-event-user-emails.ts new file mode 100644 index 000000000..f3fc250d4 --- /dev/null +++ b/hooks/use-event-user-emails.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { collectUserCalendarAddresses } from "@/lib/calendar-participants"; + +/** + * The user's addresses for one event. With several logins in the calendar + * (pim-12), an event of another login is answered and edited as that login, + * so its participant is found by that login's address, not the active + * login's ones (`activeEmails`). + */ +export function eventUserEmails( + event: { localAccountId?: string } | null | undefined, + activeEmails: string[], + activeLocalAccountId: string | null, + accounts: ReadonlyArray<{ id: string; email?: string; username?: string }>, +): string[] { + const owner = event?.localAccountId; + if (!owner || owner === activeLocalAccountId) return activeEmails; + const account = accounts.find((a) => a.id === owner); + if (!account) return activeEmails; + const own = collectUserCalendarAddresses([account.email, account.username?.includes("@") ? account.username : null]); + return own.length > 0 ? own : activeEmails; +} + +export function useEventUserEmails(activeEmails: string[]): (event: { localAccountId?: string } | null | undefined) => string[] { + const accounts = useAccountStore((s) => s.accounts); + const activeLocalAccountId = useAccountStore((s) => s.activeAccountId); + return useCallback( + (event) => eventUserEmails(event, activeEmails, activeLocalAccountId, accounts), + [activeEmails, activeLocalAccountId, accounts], + ); +} diff --git a/hooks/use-pro-multi-account-calendars.ts b/hooks/use-pro-multi-account-calendars.ts index 7db808cce..97604c083 100644 --- a/hooks/use-pro-multi-account-calendars.ts +++ b/hooks/use-pro-multi-account-calendars.ts @@ -1,16 +1,19 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-store"; +import { useTaskStore } from "@/stores/task-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; /** - * When the Pro shell is the active interface, aggregate calendars from - * every connected account so the calendar sidebar lists them all - the - * same way [[use-pro-multi-account-mailboxes]] does for mail folders. + * Aggregate calendars from every connected account so the calendar sidebar + * lists them all, grouped by account, and the grid shows the ticked ones + * together: in the Pro shell (as [[use-pro-multi-account-mailboxes]] does + * for mail folders), and in the standard interface once two or more + * logins with calendars are signed in (pim-12). * * Returns the resolved list of `{ localAccountId, client }` pairs so the * caller (calendar page) can fetch events the same way without @@ -27,10 +30,7 @@ export function useProMultiAccountCalendars(start: string | null, end: string | const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents); - const enabled = proInterface || isEmbedded; - - const accountClients = useMemo(() => { - if (!enabled) return []; + const connected = useMemo(() => { const getClientForAccount = useAuthStore.getState().getClientForAccount; const pairs: CalendarAccountClient[] = []; for (const account of accounts) { @@ -42,7 +42,29 @@ export function useProMultiAccountCalendars(start: string | null, end: string | return pairs; // accounts identity changes whenever the connected set or login states // change, so this is the only dependency we need. - }, [enabled, accounts]); + }, [accounts]); + + const enabled = proInterface || isEmbedded || connected.length > 1; + const accountClients = useMemo(() => (enabled ? connected : []), [enabled, connected]); + + // The stores route their own loads (a push, a refresh) and each task + // change through the logins while several are shown. Going back to one + // login reloads it with plain ids. + const wasAggregated = useRef(false); + useEffect(() => { + const aggregated = enabled && accountClients.length > 0; + useCalendarStore.getState().setAccountClients(aggregated ? accountClients : null, activeAccountId); + useTaskStore.getState().setAccountClients(aggregated ? accountClients : null, useAuthStore.getState().client); + if (!aggregated && wasAggregated.current) { + const client = useAuthStore.getState().client; + const calendarStore = useCalendarStore.getState(); + if (client) { + void calendarStore.fetchCalendars(client); + if (calendarStore.dateRange) void calendarStore.fetchEvents(client, calendarStore.dateRange.start, calendarStore.dateRange.end); + } + } + wasAggregated.current = aggregated; + }, [enabled, accountClients, activeAccountId]); // Fetch calendars whenever the set of connected calendar-capable accounts // changes. Skips when there isn't an active account yet (auth still diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 6a19f659f..9be57d3db 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -917,6 +917,8 @@ export interface CalendarTask { originalId?: string; accountId?: string; isShared?: boolean; + /** Client-only: the login the task belongs to while the calendar shows several (pim-12). */ + localAccountId?: string; } /** diff --git a/stores/__tests__/multi-login-calendar-ux-pim.test.ts b/stores/__tests__/multi-login-calendar-ux-pim.test.ts new file mode 100644 index 000000000..65886cef0 --- /dev/null +++ b/stores/__tests__/multi-login-calendar-ux-pim.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useTaskStore } from '../task-store'; +import { useCalendarStore, resolveMutationTarget } from '../calendar-store'; +import { setClientLookup } from '../client-registry'; +import { eventUserEmails } from '@/hooks/use-event-user-emails'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// pim-12: with two signed-in logins the calendar shows both logins' +// calendars, and a change to an item of the other login goes through that +// login's client into its account. + +function task(id: string, calendarId: string): CalendarTask { + return { + id, calendarIds: { [calendarId]: true }, '@type': 'Task', uid: id, title: id, description: '', + due: null, start: null, duration: null, timeZone: null, showWithoutTime: true, progress: 'needs-action', + priority: 0, privacy: 'public', keywords: null, categories: null, color: null, created: null, + updated: '2026-11-01T00:00:00Z', recurrenceRules: null, alerts: null, relatedTo: null, + } as CalendarTask; +} + +function fakeClient(name: string, tasks: CalendarTask[], calendars: Partial[] = []) { + return { + name, + getCalendarTasks: vi.fn().mockResolvedValue(tasks), + createCalendarTask: vi.fn().mockImplementation(async (t: Partial) => ({ ...task('new', Object.keys(t.calendarIds ?? {})[0]), ...t, id: 'new' })), + updateCalendarTask: vi.fn().mockImplementation(async (id: string) => id), + deleteCalendarTask: vi.fn().mockResolvedValue(undefined), + getAllCalendarsWithFailures: vi.fn().mockResolvedValue({ calendars, failedAccountIds: [] }), + getAllCalendars: vi.fn().mockResolvedValue(calendars), + queryAllCalendarEvents: vi.fn().mockResolvedValue([]), + getParticipantIdentities: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient & { getCalendarTasks: ReturnType; createCalendarTask: ReturnType; updateCalendarTask: ReturnType; deleteCalendarTask: ReturnType }; +} + +describe('tasks of several logins (pim-12)', () => { + const a = fakeClient('a', [task('t1', 'e')]); + const b = fakeClient('b', [task('t9', 'b1')]); + + beforeEach(() => { + vi.clearAllMocks(); + setClientLookup((id) => (id === 'login-a' ? a : id === 'login-b' ? b : undefined)); + useTaskStore.setState({ tasks: [], accountClients: null } as never); + useTaskStore.getState().setAccountClients([{ localAccountId: 'login-a', client: a }, { localAccountId: 'login-b', client: b }]); + }); + + it('lists both logins\' tasks with their calendars named like the calendar store names them', async () => { + await useTaskStore.getState().fetchTasks(a); + const tasks = useTaskStore.getState().tasks; + expect(tasks.map((t) => [t.id, Object.keys(t.calendarIds)[0]])).toEqual([ + ['login-a::t1', 'login-a::e'], + ['login-b::t9', 'login-b::b1'], + ]); + }); + + it('makes, changes and deletes a task of the other login through that login', async () => { + await useTaskStore.getState().fetchTasks(a); + const created = await useTaskStore.getState().createTask(a, { title: 'x', calendarIds: { 'login-b::b1': true } }); + expect(b.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({ calendarIds: { b1: true } })); + expect(a.createCalendarTask).not.toHaveBeenCalled(); + expect(created?.id).toBe('login-b::new'); + + await useTaskStore.getState().toggleTaskComplete(a, useTaskStore.getState().tasks.find((t) => t.id === 'login-b::t9')!); + expect(b.updateCalendarTask).toHaveBeenCalledWith('t9', { progress: 'completed' }); + + await useTaskStore.getState().updateTask(a, 'login-b::t9', { title: 'renamed' }); + expect(b.updateCalendarTask).toHaveBeenCalledWith('t9', { title: 'renamed' }); + + await useTaskStore.getState().deleteTask(a, 'login-b::t9'); + expect(b.deleteCalendarTask).toHaveBeenCalledWith('t9'); + expect(a.updateCalendarTask).not.toHaveBeenCalled(); + expect(a.deleteCalendarTask).not.toHaveBeenCalled(); + }); + + it('moves a task into a list of the other login by making it there and removing it here', async () => { + await useTaskStore.getState().fetchTasks(a); + await useTaskStore.getState().updateTask(a, 'login-a::t1', { calendarIds: { 'login-b::b1': true } }); + expect(b.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({ calendarIds: { b1: true }, title: 't1' })); + expect(a.deleteCalendarTask).toHaveBeenCalledWith('t1'); + }); +}); + +describe('calendar loads with several logins (pim-12)', () => { + it('loads every login when a caller names one client, and a slower one-login answer does not win', async () => { + let answerSingle: (v: unknown) => void = () => {}; + const a = fakeClient('a', [], [{ id: 'c', name: 'Work' }]); + (a.getAllCalendarsWithFailures as unknown as ReturnType) + .mockImplementationOnce(() => new Promise((resolve) => { answerSingle = resolve; })); + const b = fakeClient('b', [], [{ id: 'c', name: 'Ben' }]); + useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], knownCalendarIds: null, accountClients: null } as never); + + // A one-login load starts first (the page), then the logins are known. + const single = useCalendarStore.getState().fetchCalendars(a); + useCalendarStore.getState().setAccountClients([{ localAccountId: 'login-a', client: a }, { localAccountId: 'login-b', client: b }], 'login-a'); + await useCalendarStore.getState().fetchCalendars(a); + answerSingle({ calendars: [{ id: 'c', name: 'Work' }], failedAccountIds: [] }); + await single; + + const ids = useCalendarStore.getState().calendars.map((c) => c.id).sort(); + expect(ids).toEqual(['login-a::c', 'login-b::c']); + // The other login's calendars start ticked. + expect(useCalendarStore.getState().selectedCalendarIds.sort()).toEqual(['login-a::c', 'login-b::c']); + }); +}); + +describe('a calendar shared between two signed-in logins (pim-12)', () => { + it('shows once, under the login that owns it', async () => { + const a = fakeClient('a', [], [ + { id: 'c', name: 'Work', accountId: 'ea', accountName: 'a@example.org', originalId: 'c' }, + { id: 'eb:b1', name: 'Team', accountId: 'eb', accountName: 'b@example.org', originalId: 'b1', isShared: true }, + ]); + const b = fakeClient('b', [], [{ id: 'b1', name: 'Team', accountId: 'eb', accountName: 'b@example.org', originalId: 'b1' }]); + useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], knownCalendarIds: null, accountClients: null } as never); + await useCalendarStore.getState().fetchAllAccountsCalendars([{ localAccountId: 'login-a', client: a }, { localAccountId: 'login-b', client: b }], 'login-a'); + expect(useCalendarStore.getState().calendars.map((c) => c.id).sort()).toEqual(['login-a::c', 'login-b::b1']); + }); +}); + +describe("an event made in the other login's calendar (pim-12)", () => { + it("goes through that login and is kept as that login's, though both logins use the same raw ids", async () => { + const a = fakeClient('a', []); + const created = { id: 'p', uid: 'u1', title: 'New', calendarIds: { b: true }, start: '2026-11-04T10:00:00', duration: 'PT1H' }; + const b = { ...fakeClient('b', []), createCalendarEvent: vi.fn().mockResolvedValue(created) } as unknown as IJMAPClient & { createCalendarEvent: ReturnType }; + (a as unknown as { createCalendarEvent: unknown }).createCalendarEvent = vi.fn(); + setClientLookup((id) => (id === 'login-a' ? a : id === 'login-b' ? b : undefined)); + useCalendarStore.setState({ + calendars: [ + { id: 'login-a::b', originalId: 'b', name: 'A home', localAccountId: 'login-a', accountId: 'ea' }, + { id: 'login-b::b', originalId: 'b', name: 'B home', localAccountId: 'login-b', accountId: 'eb' }, + ], + events: [{ id: 'login-a::p', originalId: 'p', title: 'Night train', calendarIds: { 'login-a::b': true }, localAccountId: 'login-a', start: '2026-11-06T22:30:00' }], + selectedCalendarIds: ['login-a::b', 'login-b::b'], + dateRange: null, + } as never); + const made = await useCalendarStore.getState().createEvent(a, { title: 'New', calendarIds: { 'login-b::b': true }, start: '2026-11-04T10:00:00' }); + expect(b.createCalendarEvent).toHaveBeenCalled(); + expect(made?.id).toBe('login-b::p'); + expect(made?.calendarIds).toEqual({ 'login-b::b': true }); + }); +}); + +describe('a change to an event the store no longer holds (pim-12)', () => { + it('still goes to the login its id names, with the raw id', () => { + expect(resolveMutationTarget([], 'login-b::h1pu2', 'occurrence')).toEqual({ realId: 'h1pu2', localAccountId: 'login-b', isOccurrence: false }); + expect(resolveMutationTarget([], 'login-b::ev:x9', 'series')).toEqual({ realId: 'x9', targetAccountId: 'ev', localAccountId: 'login-b', isOccurrence: false }); + expect(resolveMutationTarget([], 'plain', 'series')).toEqual({ realId: 'plain', isOccurrence: false }); + }); +}); + +describe('answering as the event\'s own login (pim-12)', () => { + const accounts = [{ id: 'login-a', email: 'a@example.org' }, { id: 'login-b', email: 'b@example.org' }]; + + it('uses the other login\'s address for its events and the active one\'s otherwise', () => { + expect(eventUserEmails({ localAccountId: 'login-b' }, ['a@example.org'], 'login-a', accounts)).toEqual(['b@example.org']); + expect(eventUserEmails({ localAccountId: 'login-a' }, ['a@example.org', 'alias@example.org'], 'login-a', accounts)).toEqual(['a@example.org', 'alias@example.org']); + expect(eventUserEmails({}, ['a@example.org'], 'login-a', accounts)).toEqual(['a@example.org']); + }); +}); diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 32ed4e810..7b0f8dbca 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -156,6 +156,20 @@ export function resolveMutationTarget( isOccurrence: false, }; } + // Not in the store (any more): a login's namespaced id still says which + // login it belongs to, and that login's server wants the raw id (pim-12). + const sep = id.indexOf(CROSS_ACCOUNT_ID_DELIMITER); + if (sep > 0) { + const raw = id.slice(sep + CROSS_ACCOUNT_ID_DELIMITER.length); + // `:`: an event of a calendar shared into that login. + const shared = raw.indexOf(':'); + return { + realId: shared > 0 ? raw.slice(shared + 1) : raw, + ...(shared > 0 ? { targetAccountId: raw.slice(0, shared) } : {}), + localAccountId: id.slice(0, sep), + isOccurrence: false, + }; + } return { realId: id, isOccurrence: false }; } @@ -590,6 +604,23 @@ const refreshInFlight = new Map>(); // Subscriptions whose first fetch is running inside addICalSubscription. const subscriptionsBeingAdded = new Set(); +/** + * A calendar one login sees as shared that another signed-in login owns + * shows once, under its owner (pim-12): listed twice, each of its events + * showed twice in the grid. + */ +function withoutOwnedElsewhere(calendars: Calendar[]): Calendar[] { + const owned = new Set(calendars + .filter((c) => !c.isShared && c.accountId) + .map((c) => `${c.accountId}|${c.accountName ?? ''}|${c.originalId ?? c.id}`)); + return calendars.filter((c) => !c.isShared || !owned.has(`${c.accountId}|${c.accountName ?? ''}|${c.originalId ?? c.id}`)); +} + +// Each calendar list load gets a number: only the latest one's answer is +// kept, so a one-login load that answers after the merged one (pim-12) +// doesn't put plain ids back. +let calendarsLoadSeq = 0; + export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks']; @@ -839,6 +870,15 @@ interface CalendarStore { setSupported: (supported: boolean) => void; fetchCalendars: (client: IJMAPClient) => Promise; fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise; + /** + * The logins the calendar shows while it shows several (Pro shell, or two + * signed-in logins: pim-12); null for the active login only. While set, + * fetchCalendars and fetchEvents load all of them, so a push or a refresh + * that names one client keeps the merged list. + */ + accountClients: CalendarAccountClient[] | null; + accountClientsActiveId: string | null; + setAccountClients: (accounts: CalendarAccountClient[] | null, activeLocalAccountId?: string | null) => void; /** `activeLocalAccountId`: the login whose calendars un-namespaced selected ids name. */ fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId?: string | null) => Promise; fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise; @@ -920,6 +960,8 @@ const initialState = { calendarSelectionChosen: false, knownCalendarIds: null as string[] | null, hiddenTaskListIds: [] as string[], + accountClients: null as CalendarAccountClient[] | null, + accountClientsActiveId: null as string | null, selectedEventId: null as string | null, isLoading: false, isLoadingEvents: false, @@ -944,6 +986,10 @@ export const useCalendarStore = create()( setSupported: (supported) => set({ supportsCalendar: supported }), + setAccountClients: (accounts, activeLocalAccountId) => { + set({ accountClients: accounts && accounts.length > 0 ? accounts : null, accountClientsActiveId: activeLocalAccountId ?? null }); + }, + fetchParticipantIdentities: async (client) => { if (!client.getParticipantIdentities) return; try { @@ -968,7 +1014,10 @@ export const useCalendarStore = create()( // (lib/store-epoch.ts): a slow answer for account A must not become // account B's calendar list or month view. fetchCalendars: async (client) => { + const merged = get().accountClients; + if (merged) return get().fetchAllAccountsCalendars(merged, get().accountClientsActiveId); const epoch = currentStoreEpoch(); + const seq = ++calendarsLoadSeq; set({ isLoading: true, error: null }); // Identities are independent of the calendar list; load them // alongside without blocking the grid. @@ -977,7 +1026,7 @@ export const useCalendarStore = create()( // Throws when the primary account fails, so a failed fetch leaves the // list and the selection as they were. const { calendars: fetched, failedAccountIds } = await client.getAllCalendarsWithFailures(); - if (currentStoreEpoch() !== epoch) return; + if (currentStoreEpoch() !== epoch || seq !== calendarsLoadSeq) return; const calendars = sortCalendars(fetched); // Default the visible selection to event calendars only - tasks-only // calendars stay out of the event grid. @@ -1010,6 +1059,8 @@ export const useCalendarStore = create()( }, fetchEvents: async (client, start, end, options) => { + const merged = get().accountClients; + if (merged) return get().fetchAllAccountsEvents(merged, start, end, options); const epoch = currentStoreEpoch(); refetchVisibleRange = () => get().fetchEvents(client, start, end, { silent: true }); const seq = ++rangeLoadSeq; @@ -1057,6 +1108,7 @@ export const useCalendarStore = create()( fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => { const epoch = currentStoreEpoch(); + const seq = ++calendarsLoadSeq; set({ isLoading: true, error: null }); try { // Id prefixes of the calendars that could not be loaded this time. @@ -1075,8 +1127,8 @@ export const useCalendarStore = create()( } }), ); - if (currentStoreEpoch() !== epoch) return; - const calendars = sortCalendars(results.flat()); + if (currentStoreEpoch() !== epoch || seq !== calendarsLoadSeq) return; + const calendars = sortCalendars(withoutOwnedElsewhere(results.flat())); // As for one account: tasks-only calendars stay out of the grid. const defaultSelected = calendars.filter(c => !c.isTasksOnly).map(c => c.id); set({ @@ -1186,7 +1238,9 @@ export const useCalendarStore = create()( selectedCalendarIds: get().selectedCalendarIds, }); const created = await client.createCalendarEvent(cleanEvent, sendSchedulingMessages, targetAccountId); - const mappedCreated = mapServerEventToStoreEvent(created, get().calendars, targetAccountId); + // Raw calendar ids repeat across logins: map onto the login's own (pim-12). + const loginCalendars = localAccountId ? get().calendars.filter((c) => c.localAccountId === localAccountId) : get().calendars; + const mappedCreated = mapServerEventToStoreEvent(created, loginCalendars, targetAccountId); const selectedCalendarIds = get().selectedCalendarIds; const createdCalendarIds = Object.keys(mappedCreated.calendarIds || {}); const isVisible = createdCalendarIds.some((calendarId) => selectedCalendarIds.includes(calendarId)); diff --git a/stores/task-store.ts b/stores/task-store.ts index 478c6e1a1..07692fd19 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -5,6 +5,54 @@ import { debug } from '@/lib/debug'; import { taskHooks } from '@/lib/plugin-hooks'; import { taskToView } from '@/lib/plugin-projection'; import { applyPatchObject, patchedProperties } from '@/lib/jmap/patch-object'; +import { getClientByLocalAccountId } from './client-registry'; + +/** + * One signed-in login whose tasks the list shows next to the others' + * (pim-12). Same shape as the calendar store's CalendarAccountClient. + */ +export interface TaskAccountClient { + localAccountId: string; + client: IJMAPClient; +} + +// Ids of a login's tasks and calendars when the calendar shows several +// logins: `::`, as the calendar store namespaces them. +const LOGIN_DELIMITER = '::'; + +function splitLoginId(id: string): { localAccountId: string | null; raw: string } { + const idx = id.indexOf(LOGIN_DELIMITER); + return idx >= 0 + ? { localAccountId: id.slice(0, idx), raw: id.slice(idx + LOGIN_DELIMITER.length) } + : { localAccountId: null, raw: id }; +} + +function prefixTask(task: CalendarTask, localAccountId: string): CalendarTask { + const prefix = `${localAccountId}${LOGIN_DELIMITER}`; + return { + ...task, + id: `${prefix}${task.id}`, + calendarIds: Object.fromEntries(Object.entries(task.calendarIds ?? {}).map(([id, v]) => [`${prefix}${id}`, v])), + localAccountId, + }; +} + +function stripCalendarIds(calendarIds: Record): { localAccountId: string | null; calendarIds: Record } { + let localAccountId: string | null = null; + const out: Record = {}; + for (const [id, v] of Object.entries(calendarIds)) { + const parts = splitLoginId(id); + localAccountId = localAccountId ?? parts.localAccountId; + out[parts.raw] = v; + } + return { localAccountId, calendarIds: out }; +} + +/** The client of the login an id names, else `client`. */ +function clientOf(client: IJMAPClient, localAccountId: string | null): IJMAPClient { + if (!localAccountId) return client; + return getClientByLocalAccountId(localAccountId) ?? client; +} export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -38,6 +86,15 @@ interface TaskStore { deleteTask: (client: IJMAPClient, id: string) => Promise; toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise; clearTasks: () => void; + /** + * The logins whose tasks the list shows, set while the calendar shows + * several logins; null for the active one only. Task and calendar ids + * are then `::`, and each change goes through the + * task's own login. + */ + accountClients: TaskAccountClient[] | null; + /** `refetchWith`: loads the list again in the new form when it was loaded. */ + setAccountClients: (accounts: TaskAccountClient[] | null, refetchWith?: IJMAPClient | null) => void; } // Each fetch gets a number: only the latest one's answer is kept, so an @@ -51,6 +108,18 @@ export const useTaskStore = create((set, get) => ({ showCompleted: false, isLoading: false, error: null, + accountClients: null, + setAccountClients: (accounts, refetchWith) => { + const before = get().accountClients; + const same = before === accounts || (!!before && !!accounts && before.length === accounts.length + && before.every((a, i) => a.localAccountId === accounts[i].localAccountId && a.client === accounts[i].client)); + if (same) return; + const loaded = get().tasks.length > 0 || get().isLoading; + // The ids change form: tasks of the old form must not stay. + latestFetch += 1; + set({ accountClients: accounts, tasks: [], selectedTaskId: null, isLoading: false }); + if (loaded && refetchWith) void get().fetchTasks(refetchWith); + }, setTasks: (tasks) => set({ tasks }), setSelectedTaskId: (id) => set({ selectedTaskId: id }), setFilter: (filter) => { @@ -70,7 +139,18 @@ export const useTaskStore = create((set, get) => ({ const stillCurrent = () => fetch === latestFetch; set({ isLoading: true, error: null }); try { - const tasks = await client.getCalendarTasks(calendarIds); + const accounts = get().accountClients; + const tasks = accounts + ? (await Promise.all(accounts.map(async (a) => { + const own = calendarIds + ?.map(splitLoginId) + .filter((parts) => parts.localAccountId === a.localAccountId) + .map((parts) => parts.raw); + if (own && own.length === 0) return []; + const list = await a.client.getCalendarTasks(own); + return list.map((task) => prefixTask(task, a.localAccountId)); + }))).flat() + : await client.getCalendarTasks(calendarIds); // A fetch started later decides the list. if (!stillCurrent()) return; debug.log('tasks', 'TaskStore/fetchTasks received', tasks.length, 'tasks'); @@ -92,7 +172,10 @@ export const useTaskStore = create((set, get) => ({ createTask: async (client, task) => { debug.log('tasks', 'TaskStore/createTask', task); if (!(await taskHooks.onBeforeTaskCreate.intercept(taskToView({ id: '', ...task } as CalendarTask)))) return null; - const created = await client.createCalendarTask(task); + const target = stripCalendarIds(task.calendarIds ?? {}); + const created = target.localAccountId + ? prefixTask(await clientOf(client, target.localAccountId).createCalendarTask({ ...task, calendarIds: target.calendarIds }), target.localAccountId) + : await client.createCalendarTask(task); debug.log('tasks', 'TaskStore/createTask result', { id: created.id, uid: created.uid, title: created.title }); set({ tasks: [...get().tasks, created] }); void taskHooks.onTaskCreate.emit(taskToView(created)); @@ -105,7 +188,26 @@ export const useTaskStore = create((set, get) => ({ // plugins read the patch as whole properties. const patch = patchedProperties(before ?? { id }, updates); if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch }))) return false; - const newId = await client.updateCalendarTask(id, updates); + const source = splitLoginId(id); + let newId: string; + if (source.localAccountId) { + const dest = updates.calendarIds ? stripCalendarIds(updates.calendarIds) : null; + const sourceClient = clientOf(client, source.localAccountId); + if (dest?.localAccountId && dest.localAccountId !== source.localAccountId && before) { + // Into a list of another login: make it there, then remove it here. + // In that order, so a failed create leaves the task where it was. + const { id: _id, localAccountId: _l, originalId: _o, accountId: _a, isShared: _s, ...rest } = before; + const moved = applyPatchObject(rest, { ...updates, calendarIds: dest.calendarIds }) as Partial; + const created = await clientOf(client, dest.localAccountId).createCalendarTask(moved); + await sourceClient.deleteCalendarTask(source.raw); + newId = prefixTask(created, dest.localAccountId).id; + } else { + const raw = await sourceClient.updateCalendarTask(source.raw, dest ? { ...updates, calendarIds: dest.calendarIds } : updates); + newId = !raw || raw === source.raw ? id : `${source.localAccountId}${LOGIN_DELIMITER}${raw}`; + } + } else { + newId = await client.updateCalendarTask(id, updates); + } if (newId && newId !== id) { // Moved into a calendar of another account: the server recreated it // under a new id, so reload rather than patch the old entry. (#1204) @@ -131,7 +233,8 @@ export const useTaskStore = create((set, get) => ({ deleteTask: async (client, id) => { const title = get().tasks.find(t => t.id === id)?.title || ''; if (!(await taskHooks.onBeforeTaskDelete.intercept({ taskId: id, title }))) return false; - await client.deleteCalendarTask(id); + const target = splitLoginId(id); + await clientOf(client, target.localAccountId).deleteCalendarTask(target.raw); set({ tasks: get().tasks.filter(t => t.id !== id), selectedTaskId: get().selectedTaskId === id ? null : get().selectedTaskId, @@ -145,7 +248,8 @@ export const useTaskStore = create((set, get) => ({ const updates: Partial = { progress: newProgress, }; - await client.updateCalendarTask(task.id, updates); + const target = splitLoginId(task.id); + await clientOf(client, target.localAccountId).updateCalendarTask(target.raw, updates); set({ tasks: get().tasks.map(t => t.id === task.id ? { ...t, ...updates, updated: new Date().toISOString() } : t), });