mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: show every signed-in account's calendars together
With two accounts signed in, Calendar showed only the active one's calendars. Now, as in the Pro interface, the sidebar lists each account's calendars under its name, each with its own checkbox, ticked by default, and the grid shows the ticked ones together. Creating, editing, moving, deleting and answering an event of the other account go through that account; tasks of all accounts are listed and change in their own account. - a calendar one account sees as shared and the other owns shows once - a load or push that names one account keeps the merged list - an event made in the other account's calendar no longer takes the id of the active account's event with the same raw id - a change to an event the store no longer holds still goes to the account its id names
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Calendar>[] = []) {
|
||||
return {
|
||||
name,
|
||||
getCalendarTasks: vi.fn().mockResolvedValue(tasks),
|
||||
createCalendarTask: vi.fn().mockImplementation(async (t: Partial<CalendarTask>) => ({ ...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<typeof vi.fn>; createCalendarTask: ReturnType<typeof vi.fn>; updateCalendarTask: ReturnType<typeof vi.fn>; deleteCalendarTask: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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<typeof vi.fn>)
|
||||
.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<typeof vi.fn> };
|
||||
(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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
// `<accountId>:<id>`: 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<string, Promise<void>>();
|
||||
// Subscriptions whose first fetch is running inside addICalSubscription.
|
||||
const subscriptionsBeingAdded = new Set<string>();
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
fetchEvents: (client: IJMAPClient, start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], start: string, end: string, options?: FetchEventsOptions) => Promise<void>;
|
||||
@@ -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<CalendarStore>()(
|
||||
|
||||
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<CalendarStore>()(
|
||||
// (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<CalendarStore>()(
|
||||
// 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<CalendarStore>()(
|
||||
},
|
||||
|
||||
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<CalendarStore>()(
|
||||
|
||||
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<CalendarStore>()(
|
||||
}
|
||||
}),
|
||||
);
|
||||
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<CalendarStore>()(
|
||||
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));
|
||||
|
||||
+109
-5
@@ -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: `<localAccountId>::<id>`, 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<string, boolean>): { localAccountId: string | null; calendarIds: Record<string, boolean> } {
|
||||
let localAccountId: string | null = null;
|
||||
const out: Record<string, boolean> = {};
|
||||
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<boolean>;
|
||||
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
|
||||
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 `<localAccountId>::<id>`, 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<TaskStore>((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<TaskStore>((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<TaskStore>((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<TaskStore>((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<CalendarTask>;
|
||||
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<TaskStore>((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<TaskStore>((set, get) => ({
|
||||
const updates: Partial<CalendarTask> = {
|
||||
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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user