From 58f3d8f115865dfb48efd2b3763d270dba5dd1b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:14:22 +0000 Subject: [PATCH 1/2] feat(web): notify 5 minutes before an upcoming event Compass had no way to reach a user who was not looking at the tab, so a meeting starting in five minutes went unannounced unless the calendar was on screen. Add browser notifications for upcoming timed events, opt-in and reversible from two surfaces. The opt-in is device-local and only ever written after the browser grants permission, so there is no "on but silent" state to explain: a grant revoked in site settings reads as off everywhere, and the palette offers to enable again. Permission is re-read on change and whenever the tab returns to the foreground. Scheduling rides the shared minute tick rather than a timer chain - 60s granularity is plenty for a five-minute lead, and it stays correct across sleep/wake where pending timers do not. Events that already started are never announced, so waking a laptop does not dump a burst of notifications for meetings long since begun, and each occurrence fires once, keyed on id and start time so a rescheduled event earns a fresh notification. - Palette: one toggle, labelled by the effective state, hidden where the browser has no Notification API. - Onboarding: a new showcase step between the create lesson and graduation. It is an offer, not a lesson - Enter allows, N passes, and both move on, so it adds no step a user can fail. The Notification API sits behind a port seam (mirroring toast.port.ts) so grant and deny are testable without a real browser prompt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VsQ9jUGnM7J2hUk3Kqx98F --- e2e/onboarding/shortcut-showcase.spec.ts | 36 +++- .../src/__tests__/helpers/web-test-seams.ts | 51 +++++ .../src/__tests__/utils/state/reset-stores.ts | 2 + packages/web/src/auth/posthog/track.ts | 5 +- .../src/common/constants/storage.constants.ts | 10 +- .../src/common/constants/toast.constants.ts | 1 + .../CommandPalette/CommandPalette.tsx | 6 +- .../ShortcutShowcase.test.tsx | 155 ++++++++++++++- .../ShortcutShowcase/ShortcutShowcase.tsx | 109 +++++++++-- .../ShortcutShowcase/showcase.steps.ts | 13 +- .../Sidebar/UpNextCard/useUpNextEvent.ts | 14 +- .../notifications/UpcomingEventNotifier.tsx | 10 + .../src/notifications/notification.port.ts | 99 ++++++++++ .../src/notifications/notification.storage.ts | 21 +++ .../notifications/notification.store.test.ts | 117 ++++++++++++ .../src/notifications/notification.store.ts | 103 ++++++++++ .../upcoming-notifier.logic.test.ts | 176 ++++++++++++++++++ .../notifications/upcoming-notifier.logic.ts | 100 ++++++++++ .../useNotificationCmdItems.test.ts | 80 ++++++++ .../notifications/useNotificationCmdItems.ts | 39 ++++ .../notifications/useUpcomingEventNotifier.ts | 61 ++++++ packages/web/src/views/Root.tsx | 2 + 22 files changed, 1187 insertions(+), 23 deletions(-) create mode 100644 packages/web/src/notifications/UpcomingEventNotifier.tsx create mode 100644 packages/web/src/notifications/notification.port.ts create mode 100644 packages/web/src/notifications/notification.storage.ts create mode 100644 packages/web/src/notifications/notification.store.test.ts create mode 100644 packages/web/src/notifications/notification.store.ts create mode 100644 packages/web/src/notifications/upcoming-notifier.logic.test.ts create mode 100644 packages/web/src/notifications/upcoming-notifier.logic.ts create mode 100644 packages/web/src/notifications/useNotificationCmdItems.test.ts create mode 100644 packages/web/src/notifications/useNotificationCmdItems.ts create mode 100644 packages/web/src/notifications/useUpcomingEventNotifier.ts diff --git a/e2e/onboarding/shortcut-showcase.spec.ts b/e2e/onboarding/shortcut-showcase.spec.ts index 1a8df1c8db..bfac06242b 100644 --- a/e2e/onboarding/shortcut-showcase.spec.ts +++ b/e2e/onboarding/shortcut-showcase.spec.ts @@ -41,9 +41,15 @@ test("the welcome-started practice runs the one-lesson happy path", async ({ // The practice title editor autofocuses; Enter commits it. await page.keyboard.type("Practice Event"); await page.keyboard.press("Enter"); - await expect(showcase).toContainText("young cap'n"); await expect(showcase).toContainText("Practice Event"); + // The notifications offer comes between the lesson and the exit. N passes + // it by, so this path never raises a browser permission prompt. + await expect(showcase).toContainText("Never miss a meeting"); + await page.keyboard.press("n"); + + await expect(showcase).toContainText("young cap'n"); + await page.keyboard.press("Enter"); await expect(showcase).toHaveCount(0); @@ -54,6 +60,34 @@ test("the welcome-started practice runs the one-lesson happy path", async ({ ).toBeVisible(); }); +test("taking the notifications offer opts in and moves on", async ({ + page, + context, +}) => { + // Granted up front so the offer resolves without a real browser prompt, + // which Playwright cannot click. + await context.grantPermissions(["notifications"]); + await page.goto("/week", { waitUntil: "domcontentloaded" }); + await leaveWelcome(page); + + const showcase = page.getByRole("region", { name: "Shortcut practice" }); + await page.keyboard.press("c"); + await page.keyboard.type("Practice Event"); + await page.keyboard.press("Enter"); + + await expect(showcase).toContainText("Never miss a meeting"); + await page.keyboard.press("Enter"); + + await expect(showcase).toContainText("young cap'n"); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem("compass.notifications.enabled"), + ), + ) + .toBe("true"); +}); + test("Skip to sign up leaves the practice for the signup form on step 1", async ({ page, }) => { diff --git a/packages/web/src/__tests__/helpers/web-test-seams.ts b/packages/web/src/__tests__/helpers/web-test-seams.ts index 616ef795e8..5f8f2a58e2 100644 --- a/packages/web/src/__tests__/helpers/web-test-seams.ts +++ b/packages/web/src/__tests__/helpers/web-test-seams.ts @@ -20,6 +20,12 @@ import { type ToastApi, type ToastPort, } from "@web/common/utils/toast/toast.port"; +import { + type NotificationPort, + registerNotificationPort, + resetNotificationPort, +} from "@web/notifications/notification.port"; +import { resetNotificationStoreForTests } from "@web/notifications/notification.store"; import { mock } from "bun:test"; export function createDefaultTestSessionPort(): SessionApiPort { @@ -84,6 +90,45 @@ export function createTestToastPort() { }; } +/** + * jsdom has no Notification global, so without a seam every test would see an + * unsupported browser and the notification UI would silently vanish. The + * default port is supported but ungranted — the state a first-run user is in. + */ +export function createTestNotificationPort(options?: { + permission?: NotificationPermission; + /** What requestPermission resolves to; defaults to the current permission. */ + respondWith?: NotificationPermission; + supported?: boolean; +}) { + let permission: NotificationPermission = options?.permission ?? "default"; + const permissionListeners = new Set<() => void>(); + + const show = mock(); + const requestPermission = mock(async () => { + permission = options?.respondWith ?? permission; + return permission; + }); + + const setPermission = (next: NotificationPermission) => { + permission = next; + for (const listener of permissionListeners) listener(); + }; + + const port: NotificationPort = { + isSupported: () => options?.supported ?? true, + getPermission: () => permission, + requestPermission, + show, + observePermission: (onChange) => { + permissionListeners.add(onChange); + return () => permissionListeners.delete(onChange); + }, + }; + + return { port, setPermission, mocks: { show, requestPermission } }; +} + export function createDefaultTestGoogleAuthorizationHook(): UseStartGoogleAuthorization { return () => ({ loading: false, @@ -110,6 +155,11 @@ export function createTestEmailPasswordPort() { export function installDefaultWebTestSeams(): void { registerSessionApiPort(createDefaultTestSessionPort()); registerToastPort(createTestToastPort().port); + registerNotificationPort(createTestNotificationPort().port); + // Re-seed after the port is in place: store resets run in afterEach, while + // the previous test's port is still registered, so a test that granted + // permission would otherwise leak "granted" into the next one. + resetNotificationStoreForTests(); registerUseStartGoogleAuthorizationForTests( createDefaultTestGoogleAuthorizationHook(), ); @@ -121,6 +171,7 @@ export function installDefaultWebTestSeams(): void { export function resetWebTestSeams(): void { resetSessionApiPort(); resetToastPort(); + resetNotificationPort(); resetUseStartGoogleAuthorizationForTests(); // AuthModal owns emailpassword reset — production SuperTokens patches XHR vs MSW. resetUseCompleteAuthenticationForTests(); diff --git a/packages/web/src/__tests__/utils/state/reset-stores.ts b/packages/web/src/__tests__/utils/state/reset-stores.ts index aa3bcb00c8..901970d14d 100644 --- a/packages/web/src/__tests__/utils/state/reset-stores.ts +++ b/packages/web/src/__tests__/utils/state/reset-stores.ts @@ -33,6 +33,7 @@ import { useUndoHistoryStore, } from "@web/events/stores/undo.store"; import { initialViewState, useViewStore } from "@web/events/stores/view.store"; +import { resetNotificationStoreForTests } from "@web/notifications/notification.store"; import { initialSettingsState, useSettingsStore, @@ -70,6 +71,7 @@ const storeResets: StoreReset[] = [ useTimezoneDialogStore.setState({ isOpen: false, purpose: "pin" }, true), resetCollapsedAccountsStoreForTests, resetRecentCommandsStoreForTests, + resetNotificationStoreForTests, () => useFeedbackStore.setState(useFeedbackStore.getInitialState(), true), () => useShortcutShowcaseStore.setState(initialShortcutShowcaseState, true), () => useFirstEventPromptStore.setState(initialFirstEventPromptState, true), diff --git a/packages/web/src/auth/posthog/track.ts b/packages/web/src/auth/posthog/track.ts index 65b2b7e591..1d9dc5f4b8 100644 --- a/packages/web/src/auth/posthog/track.ts +++ b/packages/web/src/auth/posthog/track.ts @@ -24,7 +24,10 @@ export type ProductEvent = | "billing_gate_shown" | "billing_gate_cta_clicked" | "shortcut_tip_shown" - | "shortcut_tip_acted_on"; + | "shortcut_tip_acted_on" + | "notifications_enabled" + | "notifications_disabled" + | "notifications_enable_denied"; /** * Fire-and-forget capture for the small set of product-activation events. diff --git a/packages/web/src/common/constants/storage.constants.ts b/packages/web/src/common/constants/storage.constants.ts index 907ba6a099..b83817b0b8 100644 --- a/packages/web/src/common/constants/storage.constants.ts +++ b/packages/web/src/common/constants/storage.constants.ts @@ -37,7 +37,11 @@ type StorageKey = | "compass.timezone.time-travel" // Browser IANA id the pin-mismatch banner is snoozed for. Absent means the // banner can show. It returns when the browser zone no longer matches. - | "compass.timezone.mismatch-snoozed-browser"; + | "compass.timezone.mismatch-snoozed-browser" + // Device-local opt-in for upcoming-event browser notifications. Only ever + // written "true" right after the browser grants permission, so a stale flag + // can never outlive a revoked grant (the permission is re-read on load). + | "compass.notifications.enabled"; export const STORAGE_KEYS: Record< | "AUTH" @@ -60,7 +64,8 @@ export const STORAGE_KEYS: Record< | "RECENT_COMMANDS" | "DEFAULT_TIMEZONE" | "TIME_TRAVEL_TIMEZONE" - | "TIMEZONE_MISMATCH_SNOOZED_BROWSER", + | "TIMEZONE_MISMATCH_SNOOZED_BROWSER" + | "NOTIFICATIONS_ENABLED", StorageKey > = { AUTH: "compass.auth", @@ -88,4 +93,5 @@ export const STORAGE_KEYS: Record< TIME_TRAVEL_TIMEZONE: "compass.timezone.time-travel", TIMEZONE_MISMATCH_SNOOZED_BROWSER: "compass.timezone.mismatch-snoozed-browser", + NOTIFICATIONS_ENABLED: "compass.notifications.enabled", } as const; diff --git a/packages/web/src/common/constants/toast.constants.ts b/packages/web/src/common/constants/toast.constants.ts index 419cd53ea7..0113c3a89b 100644 --- a/packages/web/src/common/constants/toast.constants.ts +++ b/packages/web/src/common/constants/toast.constants.ts @@ -17,6 +17,7 @@ export const ACCOUNT_DISCONNECTED_TOAST_ID: Id = "account-disconnected"; export const EXPORT_MY_DATA_TOAST_ID: Id = "export-my-data"; export const LOGGED_OUT_TOAST_ID: Id = "logged-out"; export const EVENT_SAVE_UNAVAILABLE_TOAST_ID: Id = "event-save-unavailable"; +export const NOTIFICATIONS_STATUS_TOAST_ID: Id = "notifications-status"; const toastPalette: Record< ThemeName, diff --git a/packages/web/src/components/CommandPalette/CommandPalette.tsx b/packages/web/src/components/CommandPalette/CommandPalette.tsx index f20f4d4924..9e2d8f3193 100644 --- a/packages/web/src/components/CommandPalette/CommandPalette.tsx +++ b/packages/web/src/components/CommandPalette/CommandPalette.tsx @@ -34,6 +34,7 @@ import { shortcutShowcaseActions } from "@web/components/ShortcutShowcase/showca import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; import { type EventMutationDependencies } from "@web/events/mutations/useEventMutations"; import { useUndoRedo } from "@web/events/mutations/useUndoRedo"; +import { useNotificationCmdItems } from "@web/notifications/useNotificationCmdItems"; import { selectIsCmdPaletteOpen, settingsActions, @@ -297,6 +298,7 @@ export const CommandPalette = ({ const logoutCmdItems = useLogoutCmdItems(); const themeCmdItems = useThemeCmdItems(); const timezoneCmdItems = useTimezoneCmdItems(); + const notificationCmdItems = useNotificationCmdItems(); const { undo, redo, canUndo, canRedo } = useUndoRedo(mutationDependencies); const recentCommandIds = useRecentCommandIds(); @@ -352,6 +354,7 @@ export const CommandPalette = ({ heading: "Settings", items: [ ...timezoneCmdItems, + ...notificationCmdItems, ...authCmdItems, ...showAccountsCmdItems, ...logoutCmdItems, @@ -399,6 +402,7 @@ export const LifeCommandPalette = ({ const navigate = useNavigate(); const themeCmdItems = useThemeCmdItems(); const timezoneCmdItems = useTimezoneCmdItems(); + const notificationCmdItems = useNotificationCmdItems(); if (!open) return null; @@ -423,7 +427,7 @@ export const LifeCommandPalette = ({ { id: "settings", heading: "Settings", - items: timezoneCmdItems, + items: [...timezoneCmdItems, ...notificationCmdItems], }, ...getMoreCommandPaletteSections("life"), ]} diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx index 23892646c9..b937cc178f 100644 --- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx +++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { createTestNotificationPort } from "@web/__tests__/helpers/web-test-seams"; import { dispatchMissingKey } from "@web/__tests__/utils/keyboard.test.util"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; @@ -13,6 +14,8 @@ import { shortcutShowcaseActions, useShortcutShowcaseStore, } from "@web/components/ShortcutShowcase/showcase.store"; +import { registerNotificationPort } from "@web/notifications/notification.port"; +import { resetNotificationStoreForTests } from "@web/notifications/notification.store"; import { clearAppLockReasons, isAppLocked } from "@web/shortcuts/app-lock"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; @@ -120,8 +123,13 @@ describe("ShortcutShowcase", () => { screen.getByLabelText("Event title"), "Coffee with Alex{Enter}", ); - expect(currentStepId()).toBe("graduation"); expect(screen.getByText("Coffee with Alex")).toBeTruthy(); + + // The notifications offer sits between the lesson and the exit. N passes. + expect(currentStepId()).toBe("notifications"); + pressKey("n"); + + expect(currentStepId()).toBe("graduation"); expect(screen.getByText(/young cap'n/)).toBeTruthy(); await waitFor(() => { expect( @@ -216,8 +224,11 @@ describe("ShortcutShowcase", () => { // No idle wait or failed attempt required: the way out is always offered. await user.click(screen.getByRole("button", { name: "Do it for me" })); - expect(currentStepId()).toBe("graduation"); + expect(currentStepId()).toBe("notifications"); expect(screen.queryByRole("button", { name: "Do it for me" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: /Not now/ })); + expect(currentStepId()).toBe("graduation"); const enterCompass = screen.getByRole("button", { name: "Enter Compass" }); await waitFor(() => { expect(enterCompass).toHaveFocus(); @@ -254,6 +265,146 @@ describe("ShortcutShowcase", () => { expect(screen.queryByLabelText("Shortcut practice")).toBeNull(); }); + describe("the notifications offer", () => { + const installPort = ( + options?: Parameters[0], + ) => { + const seam = createTestNotificationPort(options); + registerNotificationPort(seam.port); + act(() => { + resetNotificationStoreForTests(); + }); + return seam; + }; + + it("asks the browser, then moves on once permission is granted", async () => { + const user = userEvent.setup(); + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + await user.click( + screen.getByRole("button", { name: /Enable notifications/ }), + ); + + expect(seam.mocks.requestPermission).toHaveBeenCalled(); + await waitFor(() => { + expect(currentStepId()).toBe("graduation"); + }); + }); + + it("moves on even when the browser blocks the request", async () => { + const user = userEvent.setup(); + installPort({ respondWith: "denied" }); + render(); + showStep("notifications"); + + await user.click( + screen.getByRole("button", { name: /Enable notifications/ }), + ); + + // A toast explains the block. Holding the user here would turn a + // one-key offer into a decision they cannot undo from this screen. + await waitFor(() => { + expect(currentStepId()).toBe("graduation"); + }); + }); + + it("takes the offer from the keyboard with Enter", async () => { + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + pressKey("Enter"); + + expect(seam.mocks.requestPermission).toHaveBeenCalled(); + await waitFor(() => { + expect(currentStepId()).toBe("graduation"); + }); + }); + + it("asks once, and lands on graduation, when Enter is pressed twice", async () => { + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + // The step does not change until the prompt resolves, so both presses + // land on the offer. Advancing twice would skip graduation entirely. + pressKey("Enter"); + pressKey("Enter"); + + await waitFor(() => { + expect(currentStepId()).toBe("graduation"); + }); + expect(seam.mocks.requestPermission).toHaveBeenCalledTimes(1); + expect(useShortcutShowcaseStore.getState().isActive).toBe(true); + }); + + it("does not double-advance when the offer is passed mid-prompt", async () => { + installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + pressKey("Enter"); + pressKey("n"); + + await waitFor(() => { + expect(currentStepId()).toBe("graduation"); + }); + // Still on graduation, not finished out from under the user. + expect(useShortcutShowcaseStore.getState().isActive).toBe(true); + }); + + it("'Not now' passes without ever prompting", async () => { + const user = userEvent.setup(); + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + await user.click(screen.getByRole("button", { name: /Not now/ })); + + expect(seam.mocks.requestPermission).not.toHaveBeenCalled(); + expect(currentStepId()).toBe("graduation"); + }); + + it("leaves the practice keys behind on the create lesson", async () => { + const user = userEvent.setup(); + installPort(); + render(); + showStep("notifications"); + + // D and S belong to buttons this step does not render; C drives a + // practice board this step is no longer teaching. + await user.keyboard("d"); + await user.keyboard("c"); + expect(currentStepId()).toBe("notifications"); + expect(screen.queryByLabelText("Event title")).toBeNull(); + }); + + it("says so, and still lets the user pass, where the API is missing", () => { + installPort({ supported: false }); + render(); + showStep("notifications"); + + expect(screen.getByText("Not supported in this browser")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Enable notifications/ }), + ).toBeNull(); + + pressKey("n"); + expect(currentStepId()).toBe("graduation"); + }); + + it("still offers the door out to the calendar", () => { + installPort(); + render(); + showStep("notifications"); + + pressKey("x"); + expect(useShortcutShowcaseStore.getState().isActive).toBe(false); + }); + }); + it("Escape skips the showcase outright", () => { render(); act(() => shortcutShowcaseActions.replay()); diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx index 1a11b3c1e3..e2829bdb50 100644 --- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx +++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx @@ -34,6 +34,8 @@ import { } from "@web/components/ShortcutShowcase/showcase.store"; import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint"; import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys"; +import { getNotificationPort } from "@web/notifications/notification.port"; +import { notificationActions } from "@web/notifications/notification.store"; import { useAppLockReason } from "@web/shortcuts/app-lock"; import { isBareLetterKey } from "@web/shortcuts/is-bare-letter-key"; import { KEYMAP } from "@web/shortcuts/keymap"; @@ -188,7 +190,28 @@ const ShowcaseTakeover: FC = () => { return; } - if (isBareLetterKey(event, KEYMAP.createEvent.hotkey.toLowerCase())) { + // The notifications offer: Enter allows, N passes. Both move on. + if (currentStepId === "notifications") { + if ( + event.key === "Enter" && + sideActionsRef.current.notificationsSupported + ) { + event.preventDefault(); + sideActionsRef.current.enableNotifications(); + return; + } + if (isBareLetterKey(event, "n")) { + event.preventDefault(); + shortcutShowcaseActions.advance(); + return; + } + } + + // Practice-board keys belong to the lesson that owns the board. + if ( + currentStepId === "create" && + isBareLetterKey(event, KEYMAP.createEvent.hotkey.toLowerCase()) + ) { event.preventDefault(); // Create never advances by itself: the lesson advances on title // commit, so C -> type -> Enter reads as one motion, not two steps. @@ -197,8 +220,8 @@ const ShowcaseTakeover: FC = () => { } // Side actions, letter-bound so the practice screen never needs a - // mouse. Only outside graduation - there Enter is the single action. - if (currentStepId !== "graduation") { + // mouse. D and S mirror buttons that only the create lesson renders. + if (currentStepId === "create") { if (isBareLetterKey(event, "d")) { event.preventDefault(); sideActionsRef.current.doItForMe(); @@ -212,11 +235,13 @@ const ShowcaseTakeover: FC = () => { sideActionsRef.current.skipToSignup(); return; } - if (isBareLetterKey(event, "x")) { - event.preventDefault(); - shortcutShowcaseActions.skip(); - return; - } + } + + // The door out stays open on every step before graduation. + if (currentStepId !== "graduation" && isBareLetterKey(event, "x")) { + event.preventDefault(); + shortcutShowcaseActions.skip(); + return; } }; @@ -234,10 +259,43 @@ const ShowcaseTakeover: FC = () => { advance(); }; + const notificationsSupported = getNotificationPort().isSupported(); + + // The browser prompt is up for as long as the user takes to answer it, and + // the step does not change while they do - so guard against asking twice. + const offerTakenRef = useRef(false); + + // The offer moves on either way: a denial is explained by the toast, and + // holding the user here would turn a one-key offer into a decision to make. + const enableNotifications = () => { + if (offerTakenRef.current) return; + offerTakenRef.current = true; + void notificationActions.enable("showcase").finally(() => { + // Only advance if the offer is still what's on screen: passing on it or + // leaving while the prompt was up already moved the user along, and a + // second advance from graduation would close the showcase outright. + const { stepIndex: current } = useShortcutShowcaseStore.getState(); + if (stepIdAt(current) !== "notifications") return; + advance(); + }); + }; + // Side-action letters for the capture listener; refs because the listener // mounts once (same pattern as graduateRef). - const sideActionsRef = useRef({ doItForMe, skipToSignup, authenticated }); - sideActionsRef.current = { doItForMe, skipToSignup, authenticated }; + const sideActionsRef = useRef({ + doItForMe, + skipToSignup, + authenticated, + enableNotifications, + notificationsSupported, + }); + sideActionsRef.current = { + doItForMe, + skipToSignup, + authenticated, + enableNotifications, + notificationsSupported, + }; const step = stepId === "create" @@ -245,7 +303,7 @@ const ShowcaseTakeover: FC = () => { ...getShowcaseStep("create"), ...getCreateLessonPhase(Boolean(practice.editor)), } - : getShowcaseStep("graduation"); + : getShowcaseStep(stepId); return (
{

{step.keycaps && }
- {stepId === "graduation" ? ( + {stepId === "graduation" && ( - ) : ( + )} + {stepId === "notifications" && ( + <> + {notificationsSupported ? ( + + ) : ( + + Not supported in this browser + + )} + + + )} + {stepId === "create" && ( <>