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" && (
<>