diff --git a/.agents/handoffs/3232.md b/.agents/handoffs/3232.md new file mode 100644 index 0000000000..7e5e2d3db1 --- /dev/null +++ b/.agents/handoffs/3232.md @@ -0,0 +1,29 @@ +--- +schema_version: 1 +task_id: "3232" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/web/src/auth/providers/provider-copy.util.ts + - path: packages/web/src/auth/providers/ProviderConnectChooser.tsx + - path: packages/web/src/auth/providers/ConnectProviderAction.tsx + - path: packages/web/src/components/Settings/SettingsModal.tsx + - path: packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx + - path: packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx +evidence: + - command: bun run verify --strict + result: "VERDICT: PASS (test:web, type-check, lint, knip, test:a11y, test:e2e)" +assumptions: + - "Microsoft reconnect copy uses Outlook as the product name so banner text matches the WP-08b example." + - "Default calendar optgroups now include (Provider) even for Google." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +P0 WP-08b: migrate Settings, sidebar, banners and toasts to the provider layer. diff --git a/packages/web/src/auth/providers/ConnectProviderAction.tsx b/packages/web/src/auth/providers/ConnectProviderAction.tsx new file mode 100644 index 0000000000..335ee13551 --- /dev/null +++ b/packages/web/src/auth/providers/ConnectProviderAction.tsx @@ -0,0 +1,61 @@ +import { type FC } from "react"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { OverlayPanelActionButton } from "@web/components/OverlayPanel/OverlayPanel"; +import { settingsShortcutAttrs } from "@web/settings/useSettingsShortcuts"; + +const SIDEBAR_BUTTON_CLASSNAME = + "c-button-compact c-button-primary w-full rounded-xs px-2 py-1.5 text-left text-xs"; + +interface ConnectProviderActionProps { + connectingLabel: string; + idleLabel: string; + kind: ProviderKind; + newAccount?: boolean; + shortcut?: string; + shortcutAttr?: boolean; + showShortcut?: boolean; + variant: "settings" | "sidebar"; +} + +export const ConnectProviderAction: FC = ({ + connectingLabel, + idleLabel, + kind, + newAccount, + shortcut, + shortcutAttr = false, + showShortcut, + variant, +}) => { + const { connect, isConnecting } = useConnectProvider(kind, { newAccount }); + const label = isConnecting ? connectingLabel : idleLabel; + + if (variant === "settings") { + return ( + + {label} + + ); + } + + return ( + + ); +}; diff --git a/packages/web/src/auth/providers/ProviderConnectChooser.tsx b/packages/web/src/auth/providers/ProviderConnectChooser.tsx new file mode 100644 index 0000000000..264af7f769 --- /dev/null +++ b/packages/web/src/auth/providers/ProviderConnectChooser.tsx @@ -0,0 +1,58 @@ +import { type FC } from "react"; +import { providerDisplayName } from "@core/types/sync/identity.contracts"; +import { ConnectProviderAction } from "@web/auth/providers/ConnectProviderAction"; +import { + CONNECT_CALENDAR_LABEL, + openingProviderCopy, +} from "@web/auth/providers/provider-copy.util"; +import { useConnectableProviders } from "@web/auth/providers/useIsProviderAvailable"; +import { OverlayPanelActions } from "@web/components/OverlayPanel/OverlayPanel"; + +interface ProviderConnectChooserProps { + showShortcuts?: boolean; + variant: "settings" | "sidebar"; +} + +export const ProviderConnectChooser: FC = ({ + showShortcuts = false, + variant, +}) => { + const connectable = useConnectableProviders(); + if (connectable.length === 0) return null; + + if (variant === "settings") { + const single = connectable.length === 1; + return ( + + {connectable.map((kind, index) => ( + + ))} + + ); + } + + return ( +
+ {connectable.map((kind) => ( + + ))} +
+ ); +}; diff --git a/packages/web/src/auth/providers/provider-availability.factory.ts b/packages/web/src/auth/providers/provider-availability.factory.ts index 073704b791..abf37aac7a 100644 --- a/packages/web/src/auth/providers/provider-availability.factory.ts +++ b/packages/web/src/auth/providers/provider-availability.factory.ts @@ -1,6 +1,13 @@ import { useEffect, useSyncExternalStore } from "react"; import { type ProviderKind } from "@core/types/sync/identity.contracts"; +const PROVIDER_KINDS: readonly ProviderKind[] = [ + "google", + "microsoft", + "apple", +]; +const NO_CONNECTABLE: ProviderKind[] = []; + export type BackendProviderAvailability = | "available" | "unavailable" @@ -58,6 +65,7 @@ export function createProviderAvailability({ apple: unavailableFlags, }; let loadPromise: Promise | undefined; + let connectableCache: ProviderKind[] = NO_CONNECTABLE; const emit = () => { for (const listener of listeners) { @@ -83,6 +91,18 @@ export function createProviderAvailability({ return ready; }; + const connectableSnapshot = (): ProviderKind[] => { + const next = PROVIDER_KINDS.filter((kind) => snapshotFor(kind, "connect")); + if ( + next.length === connectableCache.length && + next.every((kind, index) => kind === connectableCache[index]) + ) { + return connectableCache; + } + connectableCache = next.length === 0 ? NO_CONNECTABLE : next; + return connectableCache; + }; + const load = async (): Promise => { if (!loadPromise) { loadPromise = getConfig() @@ -120,6 +140,20 @@ export function createProviderAvailability({ return available; }; + const useConnectableProviders = (): ProviderKind[] => { + const connectable = useSyncExternalStore( + subscribe, + connectableSnapshot, + connectableSnapshot, + ); + + useEffect(() => { + void load(); + }, []); + + return connectable; + }; + const useIsGoogleAvailable = (): boolean => useIsProviderAvailable("google", "signIn"); @@ -133,6 +167,7 @@ export function createProviderAvailability({ apple: unavailableFlags, }; loadPromise = undefined; + connectableCache = NO_CONNECTABLE; emit(); }; @@ -171,5 +206,6 @@ export function createProviderAvailability({ useIsGoogleAvailable, useIsConnectGoogleAvailable, useIsProviderAvailable, + useConnectableProviders, }; } diff --git a/packages/web/src/auth/providers/provider-copy.util.test.ts b/packages/web/src/auth/providers/provider-copy.util.test.ts new file mode 100644 index 0000000000..419bc04ec0 --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.test.ts @@ -0,0 +1,104 @@ +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { + CONNECT_CALENDAR_LABEL, + calendarProductName, + connectionProvider, + defaultCalendarGroupLabel, + emptyCalendarsCopy, + openingProviderCopy, + RECONNECT_BANNER_MESSAGE, + RECONNECT_CALENDAR_LABEL, + reconnectPointerHint, + reconnectToastBody, + reconnectToastTitle, + relabelConnectCommand, +} from "./provider-copy.util"; +import { describe, expect, it } from "bun:test"; + +describe("provider copy", () => { + it("defaults a missing connection provider to google", () => { + expect(connectionProvider(undefined)).toBe("google"); + expect(connectionProvider({ provider: "microsoft" })).toBe("microsoft"); + }); + + it("keeps Google strings byte-identical", () => { + expect(calendarProductName("google")).toBe("Google Calendar"); + expect(CONNECT_CALENDAR_LABEL.google).toBe("Connect Google Calendar"); + expect(RECONNECT_CALENDAR_LABEL.google).toBe("Reconnect Google Calendar"); + expect(RECONNECT_BANNER_MESSAGE.google).toBe( + "Google Calendar needs reconnecting.", + ); + expect(openingProviderCopy("google")).toBe("Opening Google…"); + expect(emptyCalendarsCopy(["google"])).toBe( + "Connect Google to see your calendars.", + ); + expect(defaultCalendarGroupLabel("ahab@pequod.com", "google")).toBe( + "ahab@pequod.com (Google)", + ); + expect(reconnectToastTitle("google", "lance@example.com")).toBe( + "Google Calendar disconnected (lance@example.com)", + ); + expect(reconnectToastTitle("google")).toBe("Google Calendar disconnected"); + expect(reconnectToastBody("google", "lance@example.com")).toBe( + "Access for lance@example.com expired or was revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.", + ); + expect(reconnectToastBody("google")).toBe( + "This happens when access expires or is revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.", + ); + expect(reconnectPointerHint("google")).toBe( + "Press G to reconnect Google Calendar.", + ); + }); + + it("names Microsoft as Outlook in reconnect copy", () => { + expect(RECONNECT_BANNER_MESSAGE.microsoft).toBe( + "Outlook needs reconnecting.", + ); + expect(reconnectToastTitle("microsoft", "ada@outlook.com")).toBe( + "Outlook disconnected (ada@outlook.com)", + ); + expect(reconnectToastBody("microsoft", "ada@outlook.com")).toBe( + "Access for ada@outlook.com expired or was revoked. Your events are still safe in Outlook. Reconnect and Compass will re-import them.", + ); + expect(RECONNECT_CALENDAR_LABEL.microsoft).toBe("Reconnect Outlook"); + expect(CONNECT_CALENDAR_LABEL.microsoft).toBe("Connect Outlook"); + expect(openingProviderCopy("microsoft")).toBe("Opening Microsoft…"); + expect(emptyCalendarsCopy(["microsoft"])).toBe( + "Connect Microsoft to see your calendars.", + ); + expect(defaultCalendarGroupLabel("ada@outlook.com", "microsoft")).toBe( + "ada@outlook.com (Microsoft)", + ); + expect(reconnectPointerHint("microsoft")).toBe( + "Press G to reconnect Outlook.", + ); + }); + + it("uses provider-neutral empty copy when more than one provider can connect", () => { + expect(emptyCalendarsCopy(["google", "microsoft"])).toBe( + "Connect a calendar to see your calendars.", + ); + }); + + it("relabels Google connect commands for another provider", () => { + const connect = relabelConnectCommand( + { + label: "Connect Google Calendar", + icon: ArrowsClockwiseIcon, + onSelect: () => {}, + }, + "microsoft", + ); + expect(connect?.label).toBe("Connect Outlook"); + + const reconnect = relabelConnectCommand( + { + label: "Reconnect Google Calendar", + icon: ArrowsClockwiseIcon, + onSelect: () => {}, + }, + "microsoft", + ); + expect(reconnect?.label).toBe("Reconnect Outlook"); + }); +}); diff --git a/packages/web/src/auth/providers/provider-copy.util.ts b/packages/web/src/auth/providers/provider-copy.util.ts new file mode 100644 index 0000000000..f03a8c47bf --- /dev/null +++ b/packages/web/src/auth/providers/provider-copy.util.ts @@ -0,0 +1,113 @@ +import { + type ProviderKind, + providerDisplayName, +} from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; +import { type GoogleUiConfig } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; + +export function connectionProvider( + connection: Pick | null | undefined, +): ProviderKind { + return connection?.provider ?? "google"; +} + +export const CALENDAR_PRODUCT_NAME: Record = { + google: "Google Calendar", + microsoft: "Outlook", + apple: "Apple Calendar", +}; + +export function calendarProductName(kind: ProviderKind): string { + return CALENDAR_PRODUCT_NAME[kind]; +} + +export const CONNECT_CALENDAR_LABEL: Record = { + google: "Connect Google Calendar", + microsoft: "Connect Outlook", + apple: "Connect Apple Calendar", +}; + +export const RECONNECT_CALENDAR_LABEL: Record = { + google: "Reconnect Google Calendar", + microsoft: "Reconnect Outlook", + apple: "Reconnect Apple Calendar", +}; + +export const RECONNECT_BANNER_MESSAGE: Record = { + google: "Google Calendar needs reconnecting.", + microsoft: "Outlook needs reconnecting.", + apple: "Apple Calendar needs reconnecting.", +}; + +const EMPTY_CALENDARS_COPY: Record = { + google: "Connect Google to see your calendars.", + microsoft: "Connect Microsoft to see your calendars.", + apple: "Connect Apple to see your calendars.", +}; + +const EVENTS_SAFE_PLACE: Record = { + google: "Google", + microsoft: "Outlook", + apple: "Apple", +}; + +export function openingProviderCopy(kind: ProviderKind): string { + return `Opening ${providerDisplayName(kind)}…`; +} + +export function emptyCalendarsCopy( + connectable: readonly ProviderKind[], +): string { + if (connectable.length === 1) { + return EMPTY_CALENDARS_COPY[connectable[0]!]; + } + return "Connect a calendar to see your calendars."; +} + +export function defaultCalendarGroupLabel( + accountEmail: string, + kind: ProviderKind, +): string { + return `${accountEmail} (${providerDisplayName(kind)})`; +} + +export function reconnectToastTitle( + kind: ProviderKind, + accountEmail?: string | null, +): string { + const product = calendarProductName(kind); + const named = accountEmail?.trim(); + return named + ? `${product} disconnected (${named})` + : `${product} disconnected`; +} + +export function reconnectToastBody( + kind: ProviderKind, + accountEmail?: string | null, +): string { + const named = accountEmail?.trim(); + const safePlace = EVENTS_SAFE_PLACE[kind]; + if (named) { + return `Access for ${named} expired or was revoked. Your events are still safe in ${safePlace}. Reconnect and Compass will re-import them.`; + } + return `This happens when access expires or is revoked. Your events are still safe in ${safePlace}. Reconnect and Compass will re-import them.`; +} + +export function reconnectPointerHint(kind: ProviderKind): string { + return `Press G to reconnect ${calendarProductName(kind)}.`; +} + +export function relabelConnectCommand( + commandAction: GoogleUiConfig["commandAction"], + kind: ProviderKind, +): GoogleUiConfig["commandAction"] { + if (!commandAction) return null; + if (commandAction.label === CONNECT_CALENDAR_LABEL.google) { + return { ...commandAction, label: CONNECT_CALENDAR_LABEL[kind] }; + } + if (commandAction.label === RECONNECT_CALENDAR_LABEL.google) { + return { ...commandAction, label: RECONNECT_CALENDAR_LABEL[kind] }; + } + return commandAction; +} diff --git a/packages/web/src/auth/providers/useConnectProvider.test.tsx b/packages/web/src/auth/providers/useConnectProvider.test.tsx index 0ce60e9324..15b64ac8ab 100644 --- a/packages/web/src/auth/providers/useConnectProvider.test.tsx +++ b/packages/web/src/auth/providers/useConnectProvider.test.tsx @@ -24,6 +24,7 @@ const connection = ( describe("useConnectProvider", () => { beforeEach(() => { userMetadataActions.set({ + connections: [connection({})], google: { connectionState: "RECONNECT_REQUIRED", connections: [connection({})], diff --git a/packages/web/src/auth/providers/useConnectProvider.ts b/packages/web/src/auth/providers/useConnectProvider.ts index c9091d5f6b..8bf62f5686 100644 --- a/packages/web/src/auth/providers/useConnectProvider.ts +++ b/packages/web/src/auth/providers/useConnectProvider.ts @@ -20,9 +20,13 @@ import { refreshGoogleSync, useGoogleSyncRefreshSnapshot, } from "@web/auth/google/state/google.sync.refresh"; +import { + connectionProvider, + relabelConnectCommand, +} from "@web/auth/providers/provider-copy.util"; import { useIsProviderAvailable } from "@web/auth/providers/useIsProviderAvailable"; import { - selectPrimaryGoogleSyncConnection, + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { @@ -43,11 +47,17 @@ export const useConnectProvider = ( ): UseConnectGoogleResult => { const isAvailable = useIsProviderAvailable(kind, "connect"); const aggregateState = useGoogleUiState(); - const primaryConnection = useUserMetadataStore( - selectPrimaryGoogleSyncConnection, - ); + const connections = useUserMetadataStore(selectSyncConnections); + const kindPrimary = + connections.find( + (connection) => + connectionProvider(connection) === kind && + connection.connectionState === aggregateState, + ) ?? + connections.find((connection) => connectionProvider(connection) === kind) ?? + null; const scopedConnection = options?.connection; - const syncConnection = scopedConnection ?? primaryConnection; + const syncConnection = scopedConnection ?? kindPrimary; const state = scopedConnection != null && connectionHasReconnectRequired(scopedConnection) ? "RECONNECT_REQUIRED" @@ -173,17 +183,20 @@ export const useConnectProvider = ( [queryClient, refreshSnapshot.isRefreshing], ); + const googleConfig = getGoogleConnectionConfig( + state, + { + onConnectGoogle: onOpenAuth, + onRefreshGoogle: onRefresh, + }, + { + refreshGaveUp: refreshSnapshot.gaveUp, + }, + ); + return { - ...getGoogleConnectionConfig( - state, - { - onConnectGoogle: onOpenAuth, - onRefreshGoogle: onRefresh, - }, - { - refreshGaveUp: refreshSnapshot.gaveUp, - }, - ), + ...googleConfig, + commandAction: relabelConnectCommand(googleConfig.commandAction, kind), connect: onOpenAuth, connection: syncConnection, refresh: onRefresh, diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx index 39df8c1199..a0ce272404 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx +++ b/packages/web/src/auth/providers/useIsProviderAvailable.test.tsx @@ -32,4 +32,29 @@ describe("useIsProviderAvailable", () => { }); expect(getConfig).toHaveBeenCalledTimes(1); }); + + it("lists every provider whose connect flag is true", async () => { + getConfig.mockClear(); + getConfig.mockResolvedValue({ + google: { isConfigured: true }, + providers: { + google: { signIn: true, connect: true }, + microsoft: { signIn: false, connect: true }, + apple: { signIn: false, connect: false }, + }, + }); + const { resetProviderAvailabilityForTests, useConnectableProviders } = + createProviderAvailability({ + getConfig, + isGoogleAuthConfigured: true, + }); + resetProviderAvailabilityForTests(); + + const { result } = renderHook(() => useConnectableProviders()); + + expect(result.current).toEqual([]); + await waitFor(() => { + expect(result.current).toEqual(["google", "microsoft"]); + }); + }); }); diff --git a/packages/web/src/auth/providers/useIsProviderAvailable.ts b/packages/web/src/auth/providers/useIsProviderAvailable.ts index 01a66d1d5b..bedef85e0b 100644 --- a/packages/web/src/auth/providers/useIsProviderAvailable.ts +++ b/packages/web/src/auth/providers/useIsProviderAvailable.ts @@ -2,6 +2,7 @@ import { providerAvailability } from "@web/auth/providers/provider-availability. export const { useIsProviderAvailable, + useConnectableProviders, setProviderAvailabilityForTests, resetProviderAvailabilityForTests, } = providerAvailability; diff --git a/packages/web/src/auth/state/user-metadata.store.ts b/packages/web/src/auth/state/user-metadata.store.ts index df2a99e822..302810e5bf 100644 --- a/packages/web/src/auth/state/user-metadata.store.ts +++ b/packages/web/src/auth/state/user-metadata.store.ts @@ -3,6 +3,7 @@ import { devtools } from "zustand/middleware"; import { type GoogleConnectionState, type GoogleSyncConnectionSummary, + type SyncConnectionSummary, type UserMetadata, } from "@core/types/user.types"; import { IS_DEV } from "@web/common/constants/env.constants"; @@ -55,16 +56,26 @@ export const userMetadataActions = { removeConnection: (connectionId: string) => useUserMetadataStore.setState( (state) => { - if (!state.current?.google) return state; + if (!state.current) return state; + const filter = (connection: SyncConnectionSummary) => + connection.id !== connectionId; + const nextConnections = ( + state.current.connections ?? + state.current.google?.connections ?? + [] + ).filter(filter); return { current: { ...state.current, - google: { - ...state.current.google, - connections: (state.current.google.connections ?? []).filter( - (connection) => connection.id !== connectionId, - ), - }, + connections: nextConnections, + google: state.current.google + ? { + ...state.current.google, + connections: (state.current.google.connections ?? []).filter( + filter, + ), + } + : state.current.google, }, }; }, @@ -105,8 +116,19 @@ const NO_CONNECTIONS: GoogleSyncConnectionSummary[] = []; /** * Every connected provider account, in connection order. Empty when metadata - * hasn't loaded, no account is connected, or the payload predates the plural - * field. + * hasn't loaded or no account is connected. Prefers the WP-07 + * `connections[]` field and falls back to the Google overlap copy. + */ +export const selectSyncConnections = ( + state: UserMetadataState, +): SyncConnectionSummary[] => + state.current?.connections ?? + state.current?.google?.connections ?? + NO_CONNECTIONS; + +/** + * Google-only slice. Prefer {@link selectSyncConnections} for surfaces that + * render any provider. */ export const selectGoogleSyncConnections = ( state: UserMetadataState, @@ -136,6 +158,22 @@ export const selectCanSuggestContacts = (state: UserMetadataState): boolean => * which was exactly this array's own connectionState re-derived - the browser * has everything it needs to compute it locally instead. */ +function findPrimarySyncConnection( + metadata: UserMetadata | null | undefined, +): SyncConnectionSummary | null { + const connections = + metadata?.connections ?? metadata?.google?.connections ?? NO_CONNECTIONS; + if (connections.length === 0) return null; + return ( + connections.find( + (connection) => + connection.connectionState === metadata?.google?.connectionState, + ) ?? + connections[0] ?? + null + ); +} + function findPrimaryGoogleSyncConnection( google: UserMetadata["google"], ): GoogleSyncConnectionSummary | null { @@ -148,6 +186,11 @@ function findPrimaryGoogleSyncConnection( ); } +/** Store-selector form of {@link findPrimarySyncConnection}. */ +export const selectPrimarySyncConnection = ( + state: UserMetadataState, +): SyncConnectionSummary | null => findPrimarySyncConnection(state.current); + /** Store-selector form of {@link findPrimaryGoogleSyncConnection}. */ export const selectPrimaryGoogleSyncConnection = ( state: UserMetadataState, diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index fa6b3589fc..7e0113f4ee 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -1,5 +1,5 @@ import { type Calendar } from "@core/types/calendar.contracts"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { isCalendarReconnectRequired } from "@web/auth/google/state/google.reconnect.calendar"; export function getLocalCalendar(calendars: Calendar[]): Calendar | undefined { @@ -112,7 +112,7 @@ export function spansMultipleAccounts(calendars: Calendar[]): boolean { export interface AccountGroup { accountEmail: string; - connection: GoogleSyncConnectionSummary | undefined; + connection: SyncConnectionSummary | undefined; calendars: Calendar[]; } @@ -130,7 +130,7 @@ export interface AccountGroup { */ export function groupCalendarsByAccount( calendars: Calendar[], - connections: GoogleSyncConnectionSummary[], + connections: SyncConnectionSummary[], compassEmail?: string | null, ): { groups: AccountGroup[]; ungrouped: Calendar[] } { const groups: AccountGroup[] = []; diff --git a/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx b/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx index ddc29bd23c..0712cdbecf 100644 --- a/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx +++ b/packages/web/src/common/utils/toast/google-delayed.toast.test.tsx @@ -3,7 +3,7 @@ import { render, screen, within } from "@testing-library/react"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; import { pressKey } from "@web/__tests__/utils/keyboard.test.util"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; -import * as realConnectGoogle from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; import { resetBillingGateAttentionForTests, setBillingGateOwnsScreen, @@ -24,9 +24,9 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const mockRefresh = mock(); mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: () => ({ refresh: mockRefresh }) }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { useConnectProvider: () => ({ refresh: mockRefresh }) }, ); describe("GoogleDelayedToast", () => { diff --git a/packages/web/src/common/utils/toast/google-delayed.toast.tsx b/packages/web/src/common/utils/toast/google-delayed.toast.tsx index 8a0afcb09e..42d65bc993 100644 --- a/packages/web/src/common/utils/toast/google-delayed.toast.tsx +++ b/packages/web/src/common/utils/toast/google-delayed.toast.tsx @@ -1,6 +1,11 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { rememberPendingDelayed, shouldDeferAttentionToasts, @@ -22,9 +27,12 @@ interface GoogleDelayedToastProps { // Shown when Sync reports delayed / soft ATTENTION so returning users get an // actionable Refresh rather than a dead-end warning. Mirrors the reconnect -// toast layout and delegates to useConnectGoogle().refresh(). +// toast layout and delegates to useConnectProvider().refresh(). export const GoogleDelayedToast = ({ toastId }: GoogleDelayedToastProps) => { - const { refresh } = useConnectGoogle(); + const primary = useUserMetadataStore(selectPrimarySyncConnection); + const { refresh } = useConnectProvider(connectionProvider(primary), { + connection: primary, + }); const handleRefresh = () => { getToast().dismiss(toastId); diff --git a/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx b/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx index 0012cccec1..7f938555c9 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { createTestToastPort } from "@web/__tests__/helpers/web-test-seams"; import { pressKey } from "@web/__tests__/utils/keyboard.test.util"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; -import * as realConnectGoogle from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import * as realConnectProvider from "@web/auth/providers/useConnectProvider"; import { isBillingGateOwningScreen, resetBillingGateAttentionForTests, @@ -29,18 +29,12 @@ import { eventJumpActions } from "@web/shortcuts/shift-hint/event-jump.store"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const mockConnect = mock(); -const mockUseConnectGoogle = mock(() => ({ connect: mockConnect })); - -// useConnectGoogle owns the flush-pending-events -> delegation-fork -> -// legacy-popup-or-sync-redirect logic (the exact thing that drifted out of -// sync here before: this toast used to reimplement a legacy-only copy of it -// directly). Mocking the hook keeps this file testing only what it owns — -// that a click dismisses the toast and calls connect() — not re-deriving -// useConnectGoogle's own behavior. +const mockUseConnectProvider = mock(() => ({ connect: mockConnect })); + mockModuleForFile( - "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle", - realConnectGoogle, - { useConnectGoogle: mockUseConnectGoogle }, + "@web/auth/providers/useConnectProvider", + realConnectProvider, + { useConnectProvider: mockUseConnectProvider }, ); describe("GoogleReconnectToast", () => { @@ -51,19 +45,23 @@ describe("GoogleReconnectToast", () => { document.body.removeAttribute("data-app-locked"); eventJumpActions.reset(); mockConnect.mockClear(); - mockUseConnectGoogle.mockClear(); + mockUseConnectProvider.mockClear(); mocks.error.mockClear(); mocks.dismiss.mockClear(); mocks.isActive.mockReturnValue(false); registerToastPort(port); }); - const renderToast = (accountEmail?: string) => + const renderToast = ( + accountEmail?: string, + provider?: "google" | "microsoft" | "apple", + ) => render( , @@ -95,6 +93,22 @@ describe("GoogleReconnectToast", () => { ).toBeInTheDocument(); }); + it("names a Microsoft connection with Outlook copy", () => { + renderToast("ada@outlook.com", "microsoft"); + + expect( + screen.getByText("Outlook disconnected (ada@outlook.com)"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Access for ada@outlook.com expired or was revoked. Your events are still safe in Outlook. Reconnect and Compass will re-import them.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Reconnect Outlook" }), + ).toBeInTheDocument(); + }); + it("dismisses itself and starts connect() on click", () => { renderToast("lance@example.com"); diff --git a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx index bfff4ba090..1af9464896 100644 --- a/packages/web/src/common/utils/toast/google-reconnect.toast.tsx +++ b/packages/web/src/common/utils/toast/google-reconnect.toast.tsx @@ -1,10 +1,17 @@ import { createElement } from "react"; import { type Id } from "react-toastify"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { type GoogleReconnectTarget } from "@web/auth/google/state/google.reconnect.state"; import { - selectGoogleSyncConnections, + connectionProvider, + RECONNECT_CALENDAR_LABEL, + reconnectToastBody, + reconnectToastTitle, +} from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { @@ -42,13 +49,16 @@ interface GoogleReconnectToastProps { toastId: Id; accountEmail?: string | null; connectionId?: string | null; + provider?: ProviderKind; } const toastScopedConnection = ( connectionId: string | null | undefined, accountEmail: string | null | undefined, -): GoogleSyncConnectionSummary => ({ + provider: ProviderKind, +): SyncConnectionSummary => ({ id: connectionId?.trim() || "reconnect-target", + provider, state: "actionRequired", stateReason: "authorizationRevoked", lastSyncedAt: null, @@ -65,25 +75,34 @@ const toastScopedConnection = ( // stay accurate for either cause. Hooks are fine here: ToastContainer renders // inside GoogleOAuthProvider (CompassProvider). // -// Delegates to useConnectGoogle's connect() — the same trigger the command +// Delegates to useConnectProvider's connect() — the same trigger the command // palette uses — rather than driving the OAuth redirect flow directly, so // this toast can't drift out of sync with the one place that flow lives. export const GoogleReconnectToast = ({ toastId, accountEmail, connectionId, + provider: providerProp, }: GoogleReconnectToastProps) => { - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const connectionFromStore = connections.find((entry) => entry.id === connectionId) ?? connections.find((entry) => entry.accountEmail === accountEmail) ?? null; + const kind = connectionProvider( + connectionFromStore ?? (providerProp ? { provider: providerProp } : null), + ); // Props keep the target even while metadata is refetching, so Reconnect // still binds OAuth to the broken connectionId instead of adding a new one. const connection = connectionFromStore ?? - (connectionId ? toastScopedConnection(connectionId, accountEmail) : null); - const { connect } = useConnectGoogle(connection ? { connection } : undefined); + (connectionId + ? toastScopedConnection(connectionId, accountEmail, kind) + : null); + const { connect } = useConnectProvider( + kind, + connection ? { connection } : undefined, + ); const handleReconnect = () => { getToast().dismiss(toastId); @@ -95,20 +114,16 @@ export const GoogleReconnectToast = ({ return (

- {namedAccount - ? `Google Calendar disconnected (${namedAccount})` - : "Google Calendar disconnected"} + {reconnectToastTitle(kind, namedAccount)}

- {namedAccount - ? `Access for ${namedAccount} expired or was revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.` - : "This happens when access expires or is revoked. Your events are still safe in Google. Reconnect and Compass will re-import them."} + {reconnectToastBody(kind, namedAccount)}

- Reconnect Google Calendar + {RECONNECT_CALENDAR_LABEL[kind]}
); diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx index f8ec5a055a..0eb8f05ed9 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.test.tsx @@ -21,10 +21,15 @@ afterEach(() => { const renderBanner = ( kind: "reconnect" | "importFailed" | "delayed", onAction = mock(), + provider?: "google" | "microsoft" | "apple", ) => render( - + , ); @@ -48,6 +53,14 @@ describe("CalendarConnectionBanner", () => { expect(onAction).toHaveBeenCalledTimes(1); }); + it("names a Microsoft reconnect with Outlook copy", () => { + renderBanner("reconnect", mock(), "microsoft"); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Outlook needs reconnecting.", + ); + }); + it("shows a G keycap and reconnects when G is pressed", () => { const onAction = mock(); renderBanner("reconnect", onAction); diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx index d7453b4a5d..c9fe86ddfc 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx @@ -1,5 +1,7 @@ import { type FC } from "react"; +import { type ProviderKind } from "@core/types/sync/identity.contracts"; import { type CalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { RECONNECT_BANNER_MESSAGE } from "@web/auth/providers/provider-copy.util"; import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; import { POINTER_ACTION_ATTRIBUTE, @@ -16,7 +18,7 @@ const COPY: Record< { message: string; action: string } > = { reconnect: { - message: "Google Calendar needs reconnecting.", + message: RECONNECT_BANNER_MESSAGE.google, action: "Reconnect", }, importFailed: { @@ -32,13 +34,18 @@ const COPY: Record< interface CalendarConnectionBannerProps { kind: CalendarConnectionBannerKind; onAction: () => void; + provider?: ProviderKind; } export const CalendarConnectionBanner: FC = ({ kind, onAction, + provider = "google", }) => { - const { message, action } = COPY[kind]; + const { message, action } = + kind === "reconnect" + ? { message: RECONNECT_BANNER_MESSAGE[provider], action: "Reconnect" } + : COPY[kind]; const isError = kind === "reconnect" || kind === "importFailed"; const pointerAction = kind === "reconnect" ? POINTER_ACTIONS.reconnectGoogle : undefined; diff --git a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx index f0ac576120..e5a1f3e790 100644 --- a/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx +++ b/packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBannerGate.tsx @@ -1,17 +1,27 @@ import { type FC } from "react"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { getCalendarConnectionBannerKind } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; +import { connectionProvider } from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { CalendarConnectionBanner } from "@web/components/CalendarConnectionBanner/CalendarConnectionBanner"; export const CalendarConnectionBannerGate: FC = () => { - const { connect, connection, refresh, state } = useConnectGoogle(); - const kind = getCalendarConnectionBannerKind(state, connection); - if (!kind) return null; + const primary = useUserMetadataStore(selectPrimarySyncConnection); + const kind = connectionProvider(primary); + const { connect, connection, refresh, state } = useConnectProvider(kind, { + connection: primary, + }); + const bannerKind = getCalendarConnectionBannerKind(state, connection); + if (!bannerKind) return null; return ( refresh()} + kind={bannerKind} + onAction={bannerKind === "reconnect" ? connect : () => refresh()} + provider={connectionProvider(connection)} /> ); }; diff --git a/packages/web/src/components/PointerHint/PointerHint.test.tsx b/packages/web/src/components/PointerHint/PointerHint.test.tsx index 532ccb23e0..2a2a72a6d9 100644 --- a/packages/web/src/components/PointerHint/PointerHint.test.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { PointerHint } from "@web/components/PointerHint/PointerHint"; import { initialShortcutShowcaseState, @@ -111,6 +112,37 @@ describe("PointerHint", () => { ); }); + it("teaches the reconnect shortcut with Outlook copy for a Microsoft connection", () => { + userMetadataActions.set({ + connections: [ + { + id: "conn-ms", + provider: "microsoft", + state: "actionRequired", + stateReason: "authorizationRevoked", + lastSyncedAt: null, + lastHealthyAt: null, + accountEmail: "ada@outlook.com", + connectionState: "RECONNECT_REQUIRED", + canSuggestContacts: false, + }, + ], + google: { connectionState: "RECONNECT_REQUIRED", connections: [] }, + }); + render(); + + act(() => { + pointerConfusionActions.triggerHintForTests({ + actionId: POINTER_ACTIONS.reconnectGoogle, + shortcutKey: "G", + }); + }); + + expect(screen.getByRole("status")).toHaveTextContent( + "Press G to reconnect Outlook.", + ); + }); + it("teaches Esc for the up-next dismiss target", () => { render(); diff --git a/packages/web/src/components/PointerHint/PointerHint.tsx b/packages/web/src/components/PointerHint/PointerHint.tsx index 3c58983e4b..8ff975a47a 100644 --- a/packages/web/src/components/PointerHint/PointerHint.tsx +++ b/packages/web/src/components/PointerHint/PointerHint.tsx @@ -1,5 +1,13 @@ import { X } from "@phosphor-icons/react"; import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + calendarProductName, + connectionProvider, +} from "@web/auth/providers/provider-copy.util"; +import { + selectPrimarySyncConnection, + useUserMetadataStore, +} from "@web/auth/state/user-metadata.store"; import { Z_INDEX_TOOLTIP } from "@web/common/constants/web.constants"; import IconButton from "@web/components/IconButton/IconButton"; import { @@ -41,11 +49,13 @@ const Key = ({ children }: { children: string }) => ( const pointerHintMessage = ({ attempt, eventJumpKey, + provider, showcaseActive, welcomeOpen, }: { attempt: BlockedPointerAttempt | null; eventJumpKey: string | null; + provider: ReturnType; showcaseActive: boolean; welcomeOpen: boolean; }): ReactNode => { @@ -125,8 +135,8 @@ const pointerHintMessage = ({ if (attempt?.actionId === POINTER_ACTIONS.reconnectGoogle) { return ( <> - Press {CONNECTION_BANNER_SHORTCUT_KEY} to reconnect Google - Calendar. + Press {CONNECTION_BANNER_SHORTCUT_KEY} to reconnect{" "} + {calendarProductName(provider)}. ); } @@ -166,6 +176,8 @@ export const PointerHint: FC = () => { const eventJumpKey = useEventJumpStore(selectEventJumpPointerHintKey); const showcaseActive = useShortcutShowcaseStore(selectShowcaseActive); const welcomeOpen = useWelcomeGuideStore(selectWelcomeSurfaceOpen); + const primary = useUserMetadataStore(selectPrimarySyncConnection); + const provider = connectionProvider(primary); const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -189,6 +201,7 @@ export const PointerHint: FC = () => { {pointerHintMessage({ attempt, eventJumpKey, + provider, showcaseActive, welcomeOpen, })} diff --git a/packages/web/src/components/Settings/SettingsModal.test.tsx b/packages/web/src/components/Settings/SettingsModal.test.tsx index 920252e0c6..5d8d31ed32 100644 --- a/packages/web/src/components/Settings/SettingsModal.test.tsx +++ b/packages/web/src/components/Settings/SettingsModal.test.tsx @@ -9,10 +9,12 @@ import { createStoreWrapper } from "@web/__tests__/render-with-store"; import { createMockCalendar } from "@web/__tests__/utils/factories/calendar.factory"; import { mockModuleForFile } from "@web/__tests__/utils/mock-module.test.util"; import { AuthApi } from "@web/api/auth.api"; +import { setGoogleAvailabilityForTests } from "@web/auth/google/hooks/useIsGoogleAvailable/useIsGoogleAvailable"; import { markAccountReconnectRequired, resetGoogleReconnectRequiredForTests, } from "@web/auth/google/state/google.reconnect.state"; +import { setProviderAvailabilityForTests } from "@web/auth/providers/useIsProviderAvailable"; import { userMetadataActions } from "@web/auth/state/user-metadata.store"; import { UpgradeConfirmationProvider } from "@web/billing/UpgradeConfirmation/UpgradeConfirmationProvider"; import { type AppAccess } from "@web/billing/useAppAccess"; @@ -163,6 +165,7 @@ const renderSettings = ({ } = {}) => { authenticated = isAuthenticated; userMetadataActions.set({ + connections, google: { connectionState: "HEALTHY", connections }, }); const { queryClient, wrapper } = createStoreWrapper(); @@ -511,7 +514,9 @@ describe("SettingsModal", () => { const combobox = screen.getByRole("combobox", { name: "Default Calendar" }); expect( - within(combobox).getByRole("group", { name: "ahab@pequod.com" }), + within(combobox).getByRole("group", { + name: "ahab@pequod.com (Google)", + }), ).toBeInTheDocument(); }); @@ -1051,4 +1056,61 @@ describe("SettingsModal", () => { await user.keyboard("h"); expect(document.activeElement).not.toBe(screen.getByLabelText("Monday")); }); + + it("keeps today's Google add-account copy when Google is the only connectable provider", () => { + setGoogleAvailabilityForTests("available"); + renderSettings({ connections: [connection({ provider: "google" })] }); + + expect( + screen.getByRole("button", { name: "Add account" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Microsoft" }), + ).not.toBeInTheDocument(); + }); + + it("renders a Microsoft connection with Microsoft copy and a Google connection with today's copy", () => { + setGoogleAvailabilityForTests("available"); + setProviderAvailabilityForTests("microsoft", "available"); + const google = connection({ + provider: "google", + accountEmail: "ahab@gmail.com", + }); + const microsoft = connection({ + id: "connection-ms", + provider: "microsoft", + accountEmail: "ada@outlook.com", + }); + const googleCal = createMockCalendar({ + name: "Gmail", + accountEmail: "ahab@gmail.com", + }); + const outlookCal = createMockCalendar({ + name: "Outlook", + accountEmail: "ada@outlook.com", + provider: "microsoft", + }); + + renderSettings({ + connections: [google, microsoft], + calendars: [googleCal, outlookCal], + }); + + const combobox = screen.getByRole("combobox", { name: "Default Calendar" }); + expect( + within(combobox).getByRole("group", { name: "ahab@gmail.com (Google)" }), + ).toBeInTheDocument(); + expect( + within(combobox).getByRole("group", { + name: "ada@outlook.com (Microsoft)", + }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Google" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Microsoft" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Add account" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/web/src/components/Settings/SettingsModal.tsx b/packages/web/src/components/Settings/SettingsModal.tsx index 3bc1aa526d..96add47084 100644 --- a/packages/web/src/components/Settings/SettingsModal.tsx +++ b/packages/web/src/components/Settings/SettingsModal.tsx @@ -1,9 +1,8 @@ import { type FC, Suspense, useEffect, useRef, useState } from "react"; import { type Calendar } from "@core/types/calendar.contracts"; import { type CalendarId } from "@core/types/domain-primitives"; -import { type GoogleSyncConnectionSummary } from "@core/types/user.types"; +import { type SyncConnectionSummary } from "@core/types/user.types"; import { useSession } from "@web/auth/compass/session/useSession"; -import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { formatLastSyncedLabel, getGoogleSyncStatus, @@ -12,8 +11,14 @@ import { } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.util"; import { useDisconnectGoogleAccount } from "@web/auth/google/hooks/useDisconnectGoogleAccount"; import { useGoogleSyncRefreshSnapshot } from "@web/auth/google/state/google.sync.refresh"; +import { ProviderConnectChooser } from "@web/auth/providers/ProviderConnectChooser"; import { - selectGoogleSyncConnections, + connectionProvider, + defaultCalendarGroupLabel, +} from "@web/auth/providers/provider-copy.util"; +import { useConnectProvider } from "@web/auth/providers/useConnectProvider"; +import { + selectSyncConnections, useUserMetadataStore, } from "@web/auth/state/user-metadata.store"; import { PlanSection } from "@web/billing/PlanSection"; @@ -74,7 +79,7 @@ const navButtonClassName = (current: boolean) => : "c-focus-ring flex w-full items-center justify-between rounded px-2 py-1 text-left text-sm text-text-muted transition-colors hover:bg-surface-overlay hover:text-text"; /** - * The app's Settings menu (Mod+,): Accounts (timezone, calendars, Google + * The app's Settings menu (Mod+,): Accounts (timezone, calendars, provider * connections, export / delete / log out) and Billing (plan) as sibling * pages. ESC steps back a level - out of an open disconnect * confirmation first, then out of a dirty Booking form's discard @@ -131,7 +136,7 @@ export const SettingsModal: FC = () => { }, [page]); const { data } = useCalendarsQuery(); - const connections = useUserMetadataStore(selectGoogleSyncConnections); + const connections = useUserMetadataStore(selectSyncConnections); const accountEmailOrder = useConnectedAccountEmails(); // useDefaultTargetCalendar subscribes to session reconnect overrides, so // writableCalendars recomputes when a 410 lands before Sync metadata catches up. @@ -312,7 +317,7 @@ export const SettingsModal: FC = () => { interface DefaultCalendarPickerProps { calendars: Calendar[]; - connections: GoogleSyncConnectionSummary[]; + connections: SyncConnectionSummary[]; resolvedDefault: Calendar | undefined; } @@ -345,7 +350,13 @@ const DefaultCalendarPicker: FC = ({ {groups .filter((group) => group.calendars.length > 0) .map((group) => ( - + {group.calendars.map((calendar) => (