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 { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
@@ -263,12 +264,17 @@ export default function CalendarPage() {
|
||||
return subscribeToPendingWebcal(openPendingWebcal);
|
||||
}, [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(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && !hasFetched.current) {
|
||||
hasFetched.current = true;
|
||||
fetchCalendars(client);
|
||||
}
|
||||
}, [client, fetchCalendars]);
|
||||
}, [client, fetchCalendars, isEmbedded]);
|
||||
|
||||
// Auto-refresh iCal subscriptions
|
||||
useEffect(() => {
|
||||
@@ -337,10 +343,21 @@ export default function CalendarPage() {
|
||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar, fetchTasksFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded) return;
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
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(() => {
|
||||
let next: Date;
|
||||
@@ -564,12 +581,15 @@ export default function CalendarPage() {
|
||||
}, [events, client]);
|
||||
|
||||
const refetchCurrentRange = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (!client || !activeAccountId) return;
|
||||
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||
if (currentRange) {
|
||||
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||
if (!currentRange) return;
|
||||
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)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
@@ -577,8 +597,11 @@ export default function CalendarPage() {
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
|
||||
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
|
||||
: fetchCalendars(client);
|
||||
await Promise.all([
|
||||
fetchCalendars(client),
|
||||
calendarRefresh,
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
@@ -1335,6 +1358,7 @@ export default function CalendarPage() {
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
</div>
|
||||
{!isNarrow && (
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
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 type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
@@ -28,6 +29,12 @@ interface CalendarSidebarPanelProps {
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
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({
|
||||
@@ -43,6 +50,7 @@ export function CalendarSidebarPanel({
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
multiAccountMode,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
@@ -70,6 +78,26 @@ export function CalendarSidebarPanel({
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
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 sharedAccountGroups = useMemo(() => {
|
||||
const shared = calendars.filter(c => c.isShared);
|
||||
@@ -84,6 +112,53 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [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) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -268,37 +343,89 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<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 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)}
|
||||
{multiAccountMode && localAccountGroups.length > 0 ? (
|
||||
<>
|
||||
{localAccountGroups.map((group, idx) => {
|
||||
const expanded = !collapsedAccountGroups.has(group.key);
|
||||
const isActive = group.key === activeLocalAccountId;
|
||||
return (
|
||||
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
|
||||
<button
|
||||
onClick={() => toggleAccountGroup(group.key)}
|
||||
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground/90 truncate">
|
||||
{group.label}
|
||||
</span>
|
||||
{isActive && onCreateCalendar && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
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 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()}
|
||||
</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;
|
||||
accountName?: string;
|
||||
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 {
|
||||
@@ -467,6 +472,8 @@ export interface CalendarEvent {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
// See `Calendar.localAccountId` — same purpose for events.
|
||||
localAccountId?: string;
|
||||
isDraft: boolean;
|
||||
isOrigin: boolean;
|
||||
utcStart: string | null;
|
||||
|
||||
+178
-9
@@ -10,6 +10,32 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
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 +
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
calendarIds: Record<string, boolean> | undefined,
|
||||
calendars: Calendar[],
|
||||
@@ -113,6 +194,17 @@ export interface ICalSubscription {
|
||||
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 {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
@@ -129,6 +221,8 @@ interface CalendarStore {
|
||||
setSupported: (supported: boolean) => void;
|
||||
fetchCalendars: (client: IJMAPClient) => 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>;
|
||||
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, 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) => {
|
||||
set({ error: null });
|
||||
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 localAccountId = event.localAccountId;
|
||||
const cleanEvent = sanitizeOutgoingCalendarEventData({ ...event });
|
||||
if (event.calendarIds) {
|
||||
const remapped: Record<string, boolean> = {};
|
||||
for (const calId of Object.keys(event.calendarIds)) {
|
||||
const cal = get().calendars.find(c => c.id === calId);
|
||||
if (cal?.localAccountId) localAccountId = cal.localAccountId;
|
||||
if (cal?.isShared && cal.originalId) {
|
||||
targetAccountId = cal.accountId;
|
||||
remapped[cal.originalId] = true;
|
||||
} else if (cal?.originalId) {
|
||||
remapped[cal.originalId] = true;
|
||||
} else {
|
||||
remapped[calId] = true;
|
||||
}
|
||||
}
|
||||
cleanEvent.calendarIds = remapped;
|
||||
}
|
||||
client = resolveAccountClient(client, localAccountId);
|
||||
if (event.originalCalendarIds) {
|
||||
cleanEvent.calendarIds = event.originalCalendarIds;
|
||||
}
|
||||
@@ -321,8 +482,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
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;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
debug.log('calendar', 'Calendar updateEvent', {
|
||||
storeId: id,
|
||||
realId,
|
||||
@@ -404,8 +566,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
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;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
||||
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patchKey = `participants/${escapedId}/participationStatus`;
|
||||
@@ -443,8 +606,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
importEvents: async (client, events, calendarId) => {
|
||||
// Resolve shared calendar IDs
|
||||
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;
|
||||
client = resolveAccountClient(client, cal?.localAccountId);
|
||||
|
||||
// Deduplicate UIDs: Stalwart enforces UID uniqueness across all calendars.
|
||||
// - Events already in the target calendar → skip (true duplicates)
|
||||
@@ -611,8 +775,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
try {
|
||||
// Resolve shared event IDs and client-side expanded occurrence IDs
|
||||
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;
|
||||
client = resolveAccountClient(client, storeEvent?.localAccountId);
|
||||
if (sendSchedulingMessages) {
|
||||
try {
|
||||
const event = await client.getCalendarEvent(realId, targetAccountId);
|
||||
@@ -649,8 +814,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, cal?.localAccountId);
|
||||
await client.updateCalendar(realId, updates, targetAccountId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.map(c =>
|
||||
@@ -668,8 +834,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, cal?.localAccountId);
|
||||
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.map(c => {
|
||||
@@ -707,8 +874,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, cal?.localAccountId);
|
||||
await client.deleteCalendar(realId, targetAccountId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.filter(c => c.id !== calendarId),
|
||||
@@ -726,8 +894,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
set({ error: null });
|
||||
try {
|
||||
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;
|
||||
client = resolveAccountClient(client, cal?.localAccountId);
|
||||
let totalRemoved = 0;
|
||||
// Loop to handle pagination (getCalendarEvents has a 1000 limit)
|
||||
let hasMore = true;
|
||||
|
||||
Reference in New Issue
Block a user