feat: show calendars from all logged-in accounts in Pro shell

This commit is contained in:
Linus Rath
2026-05-21 18:48:55 +02:00
parent 9fbcdf7a5f
commit 7076f1ded8
5 changed files with 437 additions and 47 deletions
+31 -7
View File
@@ -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 && (
+158 -31
View File
@@ -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>
+63
View File
@@ -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 };
}
+7
View File
@@ -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
View File
@@ -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;