feat: show calendars from all logged-in accounts in Pro shell
This commit is contained in:
@@ -46,6 +46,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
|||||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||||
|
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||||
@@ -263,12 +264,17 @@ export default function CalendarPage() {
|
|||||||
return subscribeToPendingWebcal(openPendingWebcal);
|
return subscribeToPendingWebcal(openPendingWebcal);
|
||||||
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
}, [isAuthenticated, client, handleWebcalProtocolRequest]);
|
||||||
|
|
||||||
|
// Single-account fetch path. The Pro shell aggregates calendars from
|
||||||
|
// every connected account via [[useProMultiAccountCalendars]] below, so
|
||||||
|
// skip this fetch there to avoid clobbering the merged list with the
|
||||||
|
// active client's calendars only.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && !hasFetched.current) {
|
if (client && !hasFetched.current) {
|
||||||
hasFetched.current = true;
|
hasFetched.current = true;
|
||||||
fetchCalendars(client);
|
fetchCalendars(client);
|
||||||
}
|
}
|
||||||
}, [client, fetchCalendars]);
|
}, [client, fetchCalendars, isEmbedded]);
|
||||||
|
|
||||||
// Auto-refresh iCal subscriptions
|
// Auto-refresh iCal subscriptions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -337,10 +343,21 @@ export default function CalendarPage() {
|
|||||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isEmbedded) return;
|
||||||
if (client && calendars.length > 0 && dateRange) {
|
if (client && calendars.length > 0 && dateRange) {
|
||||||
fetchEvents(client, dateRange.start, dateRange.end);
|
fetchEvents(client, dateRange.start, dateRange.end);
|
||||||
}
|
}
|
||||||
}, [client, calendars.length, dateRange, fetchEvents]);
|
}, [client, calendars.length, dateRange, fetchEvents, isEmbedded]);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars(
|
||||||
|
isEmbedded ? dateRange?.start ?? null : null,
|
||||||
|
isEmbedded ? dateRange?.end ?? null : null,
|
||||||
|
);
|
||||||
|
const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||||
|
const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||||
|
|
||||||
const navigatePrev = useCallback(() => {
|
const navigatePrev = useCallback(() => {
|
||||||
let next: Date;
|
let next: Date;
|
||||||
@@ -564,12 +581,15 @@ export default function CalendarPage() {
|
|||||||
}, [events, client]);
|
}, [events, client]);
|
||||||
|
|
||||||
const refetchCurrentRange = useCallback(async () => {
|
const refetchCurrentRange = useCallback(async () => {
|
||||||
if (!client) return;
|
if (!client || !activeAccountId) return;
|
||||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||||
if (currentRange) {
|
if (!currentRange) return;
|
||||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
if (multiAccountEnabled && accountClients.length > 0) {
|
||||||
|
await fetchAllAccountsEventsFn(accountClients, activeAccountId, currentRange.start, currentRange.end);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [client, fetchEvents]);
|
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||||
|
}, [client, fetchEvents, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsEventsFn]);
|
||||||
|
|
||||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||||
// and refresh calendar data via JMAP instead of reloading the page.
|
// and refresh calendar data via JMAP instead of reloading the page.
|
||||||
@@ -577,8 +597,11 @@ export default function CalendarPage() {
|
|||||||
enabled: isAuthenticated && !!client,
|
enabled: isAuthenticated && !!client,
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||||
|
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||||
|
: fetchCalendars(client);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchCalendars(client),
|
calendarRefresh,
|
||||||
refetchCurrentRange(),
|
refetchCurrentRange(),
|
||||||
refreshAllSubscriptions(client),
|
refreshAllSubscriptions(client),
|
||||||
]);
|
]);
|
||||||
@@ -1335,6 +1358,7 @@ export default function CalendarPage() {
|
|||||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||||
client={client}
|
client={client}
|
||||||
|
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isNarrow && (
|
{!isNarrow && (
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import type { Calendar } from "@/lib/jmap/types";
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useTaskStore } from "@/stores/task-store";
|
import { useTaskStore } from "@/stores/task-store";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||||
@@ -28,6 +29,12 @@ interface CalendarSidebarPanelProps {
|
|||||||
onSubscribe?: () => void;
|
onSubscribe?: () => void;
|
||||||
onEditSubscription?: (subscriptionId: string) => void;
|
onEditSubscription?: (subscriptionId: string) => void;
|
||||||
client?: IJMAPClient | null;
|
client?: IJMAPClient | null;
|
||||||
|
/**
|
||||||
|
* When true, render one collapsible section per connected local account,
|
||||||
|
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
|
||||||
|
* by their `localAccountId` and the active account is shown first.
|
||||||
|
*/
|
||||||
|
multiAccountMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarSidebarPanel({
|
export function CalendarSidebarPanel({
|
||||||
@@ -43,6 +50,7 @@ export function CalendarSidebarPanel({
|
|||||||
onSubscribe,
|
onSubscribe,
|
||||||
onEditSubscription,
|
onEditSubscription,
|
||||||
client,
|
client,
|
||||||
|
multiAccountMode,
|
||||||
}: CalendarSidebarPanelProps) {
|
}: CalendarSidebarPanelProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const tSub = useTranslations("calendar.subscription");
|
const tSub = useTranslations("calendar.subscription");
|
||||||
@@ -70,6 +78,26 @@ export function CalendarSidebarPanel({
|
|||||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Persisted across mounts so toggle state survives tab switches in the
|
||||||
|
// Pro shell (same key family as the mail sidebar's account collapse).
|
||||||
|
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts');
|
||||||
|
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||||
|
} catch { return new Set(); }
|
||||||
|
});
|
||||||
|
const toggleAccountGroup = (key: string) => {
|
||||||
|
setCollapsedAccountGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const localAccounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
|
||||||
|
|
||||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||||
const sharedAccountGroups = useMemo(() => {
|
const sharedAccountGroups = useMemo(() => {
|
||||||
const shared = calendars.filter(c => c.isShared);
|
const shared = calendars.filter(c => c.isShared);
|
||||||
@@ -84,6 +112,53 @@ export function CalendarSidebarPanel({
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [calendars]);
|
}, [calendars]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pro / multi-account grouping: every calendar bucketed by its owning
|
||||||
|
* local account. Active account comes first, then the rest in their
|
||||||
|
* account-store order. Calendars without a `localAccountId` (e.g. the
|
||||||
|
* birthday calendar) fall into a separate "other" bucket so they still
|
||||||
|
* render.
|
||||||
|
*/
|
||||||
|
const localAccountGroups = useMemo(() => {
|
||||||
|
if (!multiAccountMode) return [];
|
||||||
|
const byAccount = new Map<string, Calendar[]>();
|
||||||
|
for (const cal of calendars) {
|
||||||
|
const key = cal.localAccountId || '__other__';
|
||||||
|
const list = byAccount.get(key) ?? [];
|
||||||
|
list.push(cal);
|
||||||
|
byAccount.set(key, list);
|
||||||
|
}
|
||||||
|
const ordered: { key: string; label: string; calendars: Calendar[] }[] = [];
|
||||||
|
// Active account first.
|
||||||
|
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
|
||||||
|
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
|
||||||
|
ordered.push({
|
||||||
|
key: activeLocalAccountId,
|
||||||
|
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
|
||||||
|
calendars: byAccount.get(activeLocalAccountId)!,
|
||||||
|
});
|
||||||
|
byAccount.delete(activeLocalAccountId);
|
||||||
|
}
|
||||||
|
// Then the rest in account-store order so the layout matches the mail sidebar.
|
||||||
|
for (const acct of localAccounts) {
|
||||||
|
if (!byAccount.has(acct.id)) continue;
|
||||||
|
ordered.push({
|
||||||
|
key: acct.id,
|
||||||
|
label: acct.label || acct.email || acct.username,
|
||||||
|
calendars: byAccount.get(acct.id)!,
|
||||||
|
});
|
||||||
|
byAccount.delete(acct.id);
|
||||||
|
}
|
||||||
|
// Any leftover buckets (deleted accounts, untagged calendars).
|
||||||
|
for (const [key, list] of byAccount.entries()) {
|
||||||
|
const fallbackLabel = key === '__other__'
|
||||||
|
? t('my_calendars')
|
||||||
|
: list[0]?.accountName || key;
|
||||||
|
ordered.push({ key, label: fallbackLabel, calendars: list });
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
|
||||||
|
|
||||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||||
};
|
};
|
||||||
@@ -268,37 +343,89 @@ export function CalendarSidebarPanel({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||||
{onCreateCalendar ? (
|
<>
|
||||||
<button
|
{localAccountGroups.map((group, idx) => {
|
||||||
onClick={onCreateCalendar}
|
const expanded = !collapsedAccountGroups.has(group.key);
|
||||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
const isActive = group.key === activeLocalAccountId;
|
||||||
title={tMgmt('add_calendar')}
|
return (
|
||||||
>
|
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||||
{t('my_calendars')}
|
<button
|
||||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
onClick={() => toggleAccountGroup(group.key)}
|
||||||
</button>
|
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
|
||||||
) : (
|
>
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
{expanded ? (
|
||||||
{t('my_calendars')}
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
</h3>
|
) : (
|
||||||
)}
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-0.5">
|
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
{personalCalendars.map(renderCalendarItem)}
|
<span className="text-xs font-semibold text-foreground/90 truncate">
|
||||||
</div>
|
{group.label}
|
||||||
|
</span>
|
||||||
{sharedAccountGroups.map((group) => (
|
{isActive && onCreateCalendar && (
|
||||||
<div key={group.accountName} className="mt-4">
|
<span
|
||||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
role="button"
|
||||||
<Share2 className="w-3 h-3" />
|
tabIndex={0}
|
||||||
{group.accountName}
|
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
|
||||||
</h3>
|
onKeyDown={(e) => {
|
||||||
<div className="space-y-0.5">
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{group.calendars.map(renderCalendarItem)}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onCreateCalendar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="space-y-0.5 mt-1">
|
||||||
|
{group.calendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||||
|
{onCreateCalendar ? (
|
||||||
|
<button
|
||||||
|
onClick={onCreateCalendar}
|
||||||
|
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
title={tMgmt('add_calendar')}
|
||||||
|
>
|
||||||
|
{t('my_calendars')}
|
||||||
|
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t('my_calendars')}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-0.5">
|
||||||
))}
|
{personalCalendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sharedAccountGroups.map((group) => (
|
||||||
|
<div key={group.accountName} className="mt-4">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
{group.accountName}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.calendars.map(renderCalendarItem)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderCalendarMenu()}
|
{renderCalendarMenu()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-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.
|
||||||
|
*
|
||||||
|
* Returns the resolved list of `{ localAccountId, client }` pairs so the
|
||||||
|
* caller (calendar page) can fetch events the same way without
|
||||||
|
* re-deriving the set.
|
||||||
|
*/
|
||||||
|
export function useProMultiAccountCalendars(start: string | null, end: string | null): {
|
||||||
|
enabled: boolean;
|
||||||
|
accountClients: CalendarAccountClient[];
|
||||||
|
} {
|
||||||
|
const isEmbedded = useIsEmbedded();
|
||||||
|
const proInterface = useSettingsStore((s) => s.proInterface);
|
||||||
|
const accounts = useAccountStore((s) => s.accounts);
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
|
const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars);
|
||||||
|
const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents);
|
||||||
|
|
||||||
|
const enabled = proInterface || isEmbedded;
|
||||||
|
|
||||||
|
const accountClients = useMemo(() => {
|
||||||
|
if (!enabled) return [];
|
||||||
|
const getClientForAccount = useAuthStore.getState().getClientForAccount;
|
||||||
|
const pairs: CalendarAccountClient[] = [];
|
||||||
|
for (const account of accounts) {
|
||||||
|
if (!account.isConnected) continue;
|
||||||
|
const client = getClientForAccount(account.id);
|
||||||
|
if (!client || !client.supportsCalendars()) continue;
|
||||||
|
pairs.push({ localAccountId: account.id, client });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
// accounts identity changes whenever the connected set or login states
|
||||||
|
// change, so this is the only dependency we need.
|
||||||
|
}, [enabled, accounts]);
|
||||||
|
|
||||||
|
// Fetch calendars whenever the set of connected calendar-capable accounts
|
||||||
|
// changes. Skips when there isn't an active account yet (auth still
|
||||||
|
// bootstrapping).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||||
|
void fetchAllAccountsCalendars(accountClients, activeAccountId);
|
||||||
|
}, [enabled, activeAccountId, accountClients, fetchAllAccountsCalendars]);
|
||||||
|
|
||||||
|
// Fetch events for the current visible date range across all accounts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !activeAccountId || accountClients.length === 0) return;
|
||||||
|
if (!start || !end) return;
|
||||||
|
void fetchAllAccountsEvents(accountClients, activeAccountId, start, end);
|
||||||
|
}, [enabled, activeAccountId, accountClients, start, end, fetchAllAccountsEvents]);
|
||||||
|
|
||||||
|
return { enabled, accountClients };
|
||||||
|
}
|
||||||
@@ -446,6 +446,11 @@ export interface Calendar {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
// Local account-store ID (per JMAP server connection). Populated when the
|
||||||
|
// Pro shell aggregates calendars from multiple connected accounts so we
|
||||||
|
// can route mutations to the right client. Distinct from `accountId`
|
||||||
|
// which is the JMAP server's own account UUID.
|
||||||
|
localAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarRights {
|
export interface CalendarRights {
|
||||||
@@ -467,6 +472,8 @@ export interface CalendarEvent {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
|
// See `Calendar.localAccountId` — same purpose for events.
|
||||||
|
localAccountId?: string;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
isOrigin: boolean;
|
isOrigin: boolean;
|
||||||
utcStart: string | null;
|
utcStart: string | null;
|
||||||
|
|||||||
+178
-9
@@ -10,6 +10,32 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
|||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||||
|
import { useAuthStore } from './auth-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the Pro shell aggregates calendars/events from every connected
|
||||||
|
* account, the entity carries a `localAccountId` pointing back to the
|
||||||
|
* owning JMAP client. Mutations need to use *that* client — the active
|
||||||
|
* client (passed in by the page) could be on a different server entirely.
|
||||||
|
* Falls back to the active client when `localAccountId` is unset or no
|
||||||
|
* matching client is registered.
|
||||||
|
*/
|
||||||
|
function resolveAccountClient<T extends IJMAPClient>(active: T, localAccountId?: string): T {
|
||||||
|
if (!localAccountId) return active;
|
||||||
|
const lookup = useAuthStore.getState().getClientForAccount(localAccountId) as T | undefined;
|
||||||
|
return lookup ?? active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the local-account namespace prefix from an id (if present). Used
|
||||||
|
* before passing ids back to a JMAP client, since the prefix only exists
|
||||||
|
* to keep multi-account ids unique inside the client-side store.
|
||||||
|
*/
|
||||||
|
function stripLocalAccountPrefix(id: string, localAccountId?: string): string {
|
||||||
|
if (!localAccountId) return id;
|
||||||
|
const prefix = `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
|
||||||
|
return id.startsWith(prefix) ? id.slice(prefix.length) : id;
|
||||||
|
}
|
||||||
|
|
||||||
// In-flight refresh dedup. Concurrent callers (auto-interval +
|
// In-flight refresh dedup. Concurrent callers (auto-interval +
|
||||||
// manual refresh, two account-switch reloads, etc.) share the same
|
// manual refresh, two account-switch reloads, etc.) share the same
|
||||||
@@ -24,6 +50,61 @@ export function isCalendarViewMode(value: unknown): value is CalendarViewMode {
|
|||||||
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
return typeof value === 'string' && CALENDAR_VIEW_MODES.includes(value as CalendarViewMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix used to namespace calendar/event IDs that belong to a non-active
|
||||||
|
* JMAP account when the Pro shell aggregates across accounts. The active
|
||||||
|
* account's IDs are left untouched so existing single-account code paths
|
||||||
|
* (links, deep-links, JMAP mutations) keep working unchanged.
|
||||||
|
*/
|
||||||
|
const CROSS_ACCOUNT_ID_DELIMITER = '::';
|
||||||
|
|
||||||
|
function buildCrossAccountIdPrefix(localAccountId: string): string {
|
||||||
|
return `${localAccountId}${CROSS_ACCOUNT_ID_DELIMITER}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixCalendarsWithLocalAccount(
|
||||||
|
calendars: Calendar[],
|
||||||
|
localAccountId: string,
|
||||||
|
isActiveAccount: boolean,
|
||||||
|
): Calendar[] {
|
||||||
|
if (isActiveAccount) {
|
||||||
|
return calendars.map((cal) => ({ ...cal, localAccountId }));
|
||||||
|
}
|
||||||
|
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
||||||
|
return calendars.map((cal) => ({
|
||||||
|
...cal,
|
||||||
|
id: `${prefix}${cal.id}`,
|
||||||
|
localAccountId,
|
||||||
|
// Make sure other accounts' calendars surface under their own section in
|
||||||
|
// the sidebar. The sidebar groups "shared" calendars by account label;
|
||||||
|
// promoting them keeps them visually separate from the active account's
|
||||||
|
// own calendars without inventing new grouping logic.
|
||||||
|
isShared: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixEventsWithLocalAccount(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
localAccountId: string,
|
||||||
|
isActiveAccount: boolean,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
if (isActiveAccount) {
|
||||||
|
return events.map((event) => ({ ...event, localAccountId }));
|
||||||
|
}
|
||||||
|
const prefix = buildCrossAccountIdPrefix(localAccountId);
|
||||||
|
return events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
id: `${prefix}${event.id}`,
|
||||||
|
localAccountId,
|
||||||
|
isShared: true,
|
||||||
|
calendarIds: event.calendarIds
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(event.calendarIds).map(([calId, v]) => [`${prefix}${calId}`, v]),
|
||||||
|
)
|
||||||
|
: event.calendarIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function mapCalendarIdsToStoreIds(
|
function mapCalendarIdsToStoreIds(
|
||||||
calendarIds: Record<string, boolean> | undefined,
|
calendarIds: Record<string, boolean> | undefined,
|
||||||
calendars: Calendar[],
|
calendars: Calendar[],
|
||||||
@@ -113,6 +194,17 @@ export interface ICalSubscription {
|
|||||||
lastRefreshed: string | null;
|
lastRefreshed: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One connected JMAP account. When the Pro shell aggregates calendars from
|
||||||
|
* every logged-in account, the page hands the calendar store a list of
|
||||||
|
* these so we can fetch + tag each account's data with its local app-store
|
||||||
|
* accountId (used to route mutations back to the right client).
|
||||||
|
*/
|
||||||
|
export interface CalendarAccountClient {
|
||||||
|
localAccountId: string;
|
||||||
|
client: IJMAPClient;
|
||||||
|
}
|
||||||
|
|
||||||
interface CalendarStore {
|
interface CalendarStore {
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
@@ -129,6 +221,8 @@ interface CalendarStore {
|
|||||||
setSupported: (supported: boolean) => void;
|
setSupported: (supported: boolean) => void;
|
||||||
fetchCalendars: (client: IJMAPClient) => Promise<void>;
|
fetchCalendars: (client: IJMAPClient) => Promise<void>;
|
||||||
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
|
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
|
||||||
|
fetchAllAccountsCalendars: (accounts: CalendarAccountClient[], activeLocalAccountId: string) => Promise<void>;
|
||||||
|
fetchAllAccountsEvents: (accounts: CalendarAccountClient[], activeLocalAccountId: string, start: string, end: string) => Promise<void>;
|
||||||
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
|
||||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
|
||||||
@@ -230,25 +324,92 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchAllAccountsCalendars: async (accounts, activeLocalAccountId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map(async ({ client, localAccountId }) => {
|
||||||
|
try {
|
||||||
|
const list = await client.getAllCalendars();
|
||||||
|
return prefixCalendarsWithLocalAccount(
|
||||||
|
list,
|
||||||
|
localAccountId,
|
||||||
|
localAccountId === activeLocalAccountId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to fetch calendars for account ${localAccountId}:`, error);
|
||||||
|
return [] as Calendar[];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const calendars = results.flat();
|
||||||
|
const { selectedCalendarIds } = get();
|
||||||
|
const validIds = calendars.map(c => c.id);
|
||||||
|
const stillValid = selectedCalendarIds.filter(id => validIds.includes(id) || id === BIRTHDAY_CALENDAR_ID);
|
||||||
|
set({
|
||||||
|
calendars,
|
||||||
|
isLoading: false,
|
||||||
|
selectedCalendarIds: stillValid.length > 0 ? stillValid : validIds,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch all-account calendars:', error);
|
||||||
|
set({ error: 'Failed to load calendars', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchAllAccountsEvents: async (accounts, activeLocalAccountId, start, end) => {
|
||||||
|
set({ isLoadingEvents: true, error: null });
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
accounts.map(async ({ client, localAccountId }) => {
|
||||||
|
try {
|
||||||
|
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
|
||||||
|
const valid = raw.filter(e => typeof e.start === 'string' && e.start);
|
||||||
|
const expanded = expandRecurringEvents(valid, start, end);
|
||||||
|
return prefixEventsWithLocalAccount(
|
||||||
|
expanded,
|
||||||
|
localAccountId,
|
||||||
|
localAccountId === activeLocalAccountId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to fetch events for account ${localAccountId}:`, error);
|
||||||
|
return [] as CalendarEvent[];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
set({ events: results.flat(), isLoadingEvents: false, dateRange: { start, end } });
|
||||||
|
} catch (error) {
|
||||||
|
debug.error('Failed to fetch all-account events:', error);
|
||||||
|
set({ error: 'Failed to load events', isLoadingEvents: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createEvent: async (client, event, sendSchedulingMessages) => {
|
createEvent: async (client, event, sendSchedulingMessages) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
// Resolve shared calendar context from calendarIds
|
// Resolve shared calendar context from calendarIds. Also pin the
|
||||||
|
// local account from the calendar so we route through that
|
||||||
|
// server's client when in multi-account Pro mode.
|
||||||
let targetAccountId = event.accountId;
|
let targetAccountId = event.accountId;
|
||||||
|
let localAccountId = event.localAccountId;
|
||||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||||
if (event.calendarIds) {
|
if (event.calendarIds) {
|
||||||
const remapped: Record<string, boolean> = {};
|
const remapped: Record<string, boolean> = {};
|
||||||
for (const calId of Object.keys(event.calendarIds)) {
|
for (const calId of Object.keys(event.calendarIds)) {
|
||||||
const cal = get().calendars.find(c => c.id === calId);
|
const cal = get().calendars.find(c => c.id === calId);
|
||||||
|
if (cal?.localAccountId) localAccountId = cal.localAccountId;
|
||||||
if (cal?.isShared && cal.originalId) {
|
if (cal?.isShared && cal.originalId) {
|
||||||
targetAccountId = cal.accountId;
|
targetAccountId = cal.accountId;
|
||||||
remapped[cal.originalId] = true;
|
remapped[cal.originalId] = true;
|
||||||
|
} else if (cal?.originalId) {
|
||||||
|
remapped[cal.originalId] = true;
|
||||||
} else {
|
} else {
|
||||||
remapped[calId] = true;
|
remapped[calId] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanEvent.calendarIds = remapped;
|
cleanEvent.calendarIds = remapped;
|
||||||
}
|
}
|
||||||
|
client = resolveAccountClient(client, localAccountId);
|
||||||
if (event.originalCalendarIds) {
|
if (event.originalCalendarIds) {
|
||||||
cleanEvent.calendarIds = event.originalCalendarIds;
|
cleanEvent.calendarIds = event.originalCalendarIds;
|
||||||
}
|
}
|
||||||
@@ -321,8 +482,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || id;
|
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||||
debug.log('calendar', 'Calendar updateEvent', {
|
debug.log('calendar', 'Calendar updateEvent', {
|
||||||
storeId: id,
|
storeId: id,
|
||||||
realId,
|
realId,
|
||||||
@@ -404,8 +566,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === eventId);
|
const storeEvent = get().events.find(e => e.id === eventId);
|
||||||
const realId = storeEvent?.originalId || eventId;
|
const realId = storeEvent?.originalId || stripLocalAccountPrefix(eventId, storeEvent?.localAccountId);
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||||
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
||||||
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||||
const patchKey = `participants/${escapedId}/participationStatus`;
|
const patchKey = `participants/${escapedId}/participationStatus`;
|
||||||
@@ -443,8 +606,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
importEvents: async (client, events, calendarId) => {
|
importEvents: async (client, events, calendarId) => {
|
||||||
// Resolve shared calendar IDs
|
// Resolve shared calendar IDs
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realCalendarId = cal?.originalId || calendarId;
|
const realCalendarId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
|
client = resolveAccountClient(client, cal?.localAccountId);
|
||||||
|
|
||||||
// Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
|
// Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
|
||||||
// - Events already in the target calendar → skip (true duplicates)
|
// - Events already in the target calendar → skip (true duplicates)
|
||||||
@@ -611,8 +775,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
try {
|
try {
|
||||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||||
const storeEvent = get().events.find(e => e.id === id);
|
const storeEvent = get().events.find(e => e.id === id);
|
||||||
const realId = storeEvent?.originalId || id;
|
const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId);
|
||||||
const targetAccountId = storeEvent?.accountId;
|
const targetAccountId = storeEvent?.accountId;
|
||||||
|
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||||
if (sendSchedulingMessages) {
|
if (sendSchedulingMessages) {
|
||||||
try {
|
try {
|
||||||
const event = await client.getCalendarEvent(realId, targetAccountId);
|
const event = await client.getCalendarEvent(realId, targetAccountId);
|
||||||
@@ -649,8 +814,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realId = cal?.originalId || calendarId;
|
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
|
client = resolveAccountClient(client, cal?.localAccountId);
|
||||||
await client.updateCalendar(realId, updates, targetAccountId);
|
await client.updateCalendar(realId, updates, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: state.calendars.map(c =>
|
calendars: state.calendars.map(c =>
|
||||||
@@ -668,8 +834,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realId = cal?.originalId || calendarId;
|
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
|
client = resolveAccountClient(client, cal?.localAccountId);
|
||||||
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: state.calendars.map(c => {
|
calendars: state.calendars.map(c => {
|
||||||
@@ -707,8 +874,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realId = cal?.originalId || calendarId;
|
const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
|
client = resolveAccountClient(client, cal?.localAccountId);
|
||||||
await client.deleteCalendar(realId, targetAccountId);
|
await client.deleteCalendar(realId, targetAccountId);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
calendars: state.calendars.filter(c => c.id !== calendarId),
|
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||||
@@ -726,8 +894,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
|||||||
set({ error: null });
|
set({ error: null });
|
||||||
try {
|
try {
|
||||||
const cal = get().calendars.find(c => c.id === calendarId);
|
const cal = get().calendars.find(c => c.id === calendarId);
|
||||||
const realCalId = cal?.originalId || calendarId;
|
const realCalId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId);
|
||||||
const targetAccountId = cal?.accountId;
|
const targetAccountId = cal?.accountId;
|
||||||
|
client = resolveAccountClient(client, cal?.localAccountId);
|
||||||
let totalRemoved = 0;
|
let totalRemoved = 0;
|
||||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user