diff --git a/e2e/onboarding/shortcut-showcase.spec.ts b/e2e/onboarding/shortcut-showcase.spec.ts index b713074b9c..45a33e6dab 100644 --- a/e2e/onboarding/shortcut-showcase.spec.ts +++ b/e2e/onboarding/shortcut-showcase.spec.ts @@ -57,6 +57,11 @@ test("the welcome-started practice runs create then D through graduation", async await page.keyboard.press("d"); } + // Not a mission, so it carries no chip; N passes without prompting. + await expect(showcase).toContainText("Never miss a meeting"); + await expect(showcase).not.toContainText("Mission 7 of"); + await page.keyboard.press("n"); + await expect(showcase).toContainText("young cap'n"); await page.keyboard.press("Enter"); await expect(showcase).toHaveCount(0); @@ -66,6 +71,39 @@ test("the welcome-started practice runs create then D through graduation", 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"); + + // D assists through the remaining missions to reach the offer. + for (let i = 0; i < 5; i += 1) { + await page.keyboard.press("d"); + } + + 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..2f237eac7d 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,48 @@ 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 isSupported = () => options?.supported ?? true; + + const port: NotificationPort = { + isSupported, + // Matches production: with no Notification API there is nothing to grant. + getPermission: () => (isSupported() ? permission : "denied"), + 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 +158,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 +174,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 c74dce8981..708f3c54bd 100644 --- a/packages/web/src/auth/posthog/track.ts +++ b/packages/web/src/auth/posthog/track.ts @@ -22,7 +22,10 @@ export type ProductEvent = | "trial_gate_shown" | "trial_gate_cta_clicked" | "billing_gate_shown" - | "billing_gate_cta_clicked"; + | "billing_gate_cta_clicked" + | "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..3ff10064c6 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, @@ -420,6 +423,9 @@ export const LifeCommandPalette = ({ heading: "Appearance", items: themeCmdItems, }, + // No event notifications here: the Life route sits outside the + // authenticated layout that mounts the notifier, so offering the + // toggle would promise nudges this route can never deliver. { id: "settings", heading: "Settings", diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx index 5db0e9d6fd..c43a308e26 100644 --- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx +++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.test.tsx @@ -1,6 +1,7 @@ import { resolveModifier } from "@tanstack/react-hotkeys"; import { act, render, screen, waitFor, within } 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"; @@ -15,6 +16,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"; @@ -220,6 +223,11 @@ describe("ShortcutShowcase", () => { await user.click(screen.getByRole("button", { name: "Do it for me" })); } + // The loop above is untouched by the notifications step: it is not a + // mission, so it sits after the last one rather than inside the count. + expect(currentStepId()).toBe("notifications"); + await user.click(screen.getByRole("button", { name: "Not now" })); + expect(currentStepId()).toBe("graduation"); expect(screen.queryByRole("button", { name: "Do it for me" })).toBeNull(); const enterCompass = screen.getByRole("button", { name: "Enter Compass" }); @@ -300,7 +308,8 @@ describe("ShortcutShowcase", () => { expect(screen.getByLabelText("Practice command palette")).toBeTruthy(); pressKey("Enter"); - expect(currentStepId()).toBe("graduation"); + // The last mission hands off to the notifications offer, then graduation. + expect(currentStepId()).toBe("notifications"); }); it("offers 'Skip to sign up' from the first step and leaves on the first click", async () => { @@ -362,4 +371,181 @@ describe("ShortcutShowcase", () => { pressKey("Escape"); expect(useShortcutShowcaseStore.getState().isActive).toBe(false); }); + + 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("lets Enter activate whichever button has focus", async () => { + const user = userEvent.setup(); + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + // Enter is the step's shortcut, but a focused button owns it first: + // asking to leave must not raise a permission prompt instead. + screen.getByRole("button", { name: /Skip to calendar/ }).focus(); + await user.keyboard("{Enter}"); + + expect(seam.mocks.requestPermission).not.toHaveBeenCalled(); + expect(useShortcutShowcaseStore.getState().isActive).toBe(false); + }); + + it("does not raise the prompt from a held Enter carried in from typing", () => { + const seam = installPort({ respondWith: "granted" }); + render(); + showStep("notifications"); + + // Auto-repeat from the Enter that committed the practice title lands + // here once the editor unmounts. + pressKey("Enter", { repeat: true }); + + expect(seam.mocks.requestPermission).not.toHaveBeenCalled(); + expect(currentStepId()).toBe("notifications"); + }); + + 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 U belong to buttons this step does not render; C drives a + // practice board this step is not teaching. + await user.keyboard("d"); + await user.keyboard("u"); + 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("carries no mission chip, because it teaches no key", () => { + installPort(); + render(); + showStep("notifications"); + + expect(screen.queryByText(/^Mission \d+ of \d+$/)).toBeNull(); + }); + + it("still offers the door out to the calendar", () => { + installPort(); + render(); + showStep("notifications"); + + pressKey("x"); + expect(useShortcutShowcaseStore.getState().isActive).toBe(false); + }); + }); }); diff --git a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx index f894154c8c..1af605c0eb 100644 --- a/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx +++ b/packages/web/src/components/ShortcutShowcase/ShortcutShowcase.tsx @@ -46,6 +46,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, @@ -240,6 +242,33 @@ const ShowcaseTakeover: FC = () => { return; } + // The notifications offer: Enter allows, N passes. Both move on. + if (currentStepId === "notifications") { + // Unlike graduation, this step renders several buttons, so Enter is + // the step's shortcut only when no button owns it - otherwise + // "Not now" and "Skip to calendar" would raise a permission prompt + // instead of doing what they say. Auto-repeat is ignored too: the + // Enter that committed a practice title must not carry into the + // offer once the input unmounts. + const focusedButton = + event.target instanceof HTMLElement && event.target.closest("button"); + if ( + event.key === "Enter" && + !event.repeat && + !focusedButton && + sideActionsRef.current.notificationsSupported + ) { + event.preventDefault(); + sideActionsRef.current.enableNotifications(); + return; + } + if (isBareLetterKey(event, "n")) { + event.preventDefault(); + shortcutShowcaseActions.advance(); + return; + } + } + if (currentStepId === "pageJump") { const digit = /^Digit([12])$/.exec(event.code); if (digit && hasRevealedJumpsRef.current) { @@ -260,7 +289,12 @@ const ShowcaseTakeover: FC = () => { } } - if (isBareLetterKey(event, KEYMAP.createEvent.hotkey.toLowerCase())) { + // Every other non-graduation step teaches the board; the notifications + // offer does not, so C must not open a practice draft behind it. + if ( + currentStepId !== "notifications" && + isBareLetterKey(event, KEYMAP.createEvent.hotkey.toLowerCase()) + ) { event.preventDefault(); apply(createDraft); return; @@ -304,18 +338,22 @@ const ShowcaseTakeover: FC = () => { } if (currentStepId !== "graduation") { - if (isBareLetterKey(event, "d")) { - event.preventDefault(); - sideActionsRef.current.doItForMe(); - return; - } - if ( - isBareLetterKey(event, "u") && - !sideActionsRef.current.authenticated - ) { - event.preventDefault(); - sideActionsRef.current.skipToSignup(); - return; + // D and U are practice affordances: the notifications offer has no + // board action to perform, and no lesson to skip past. X still leaves. + if (currentStepId !== "notifications") { + if (isBareLetterKey(event, "d")) { + event.preventDefault(); + sideActionsRef.current.doItForMe(); + return; + } + if ( + isBareLetterKey(event, "u") && + !sideActionsRef.current.authenticated + ) { + event.preventDefault(); + sideActionsRef.current.skipToSignup(); + return; + } } if (isBareLetterKey(event, "x")) { event.preventDefault(); @@ -374,8 +412,43 @@ const ShowcaseTakeover: FC = () => { if (stepId === "palette") advance(); }; - const sideActionsRef = useRef({ doItForMe, skipToSignup, authenticated }); - sideActionsRef.current = { doItForMe, skipToSignup, authenticated }; + const notificationsSupported = getNotificationPort().isSupported(); + const offerTakenRef = useRef(false); + // A browser prompt stays up until the user answers it, and some never get + // answered - so say so on the button rather than letting it look live. + const [offerPending, setOfferPending] = useState(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; + setOfferPending(true); + void notificationActions.enable("showcase").finally(() => { + setOfferPending(false); + // 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(); + }); + }; + + const sideActionsRef = useRef({ + doItForMe, + skipToSignup, + authenticated, + enableNotifications, + notificationsSupported, + }); + sideActionsRef.current = { + doItForMe, + skipToSignup, + authenticated, + enableNotifications, + notificationsSupported, + }; const step = stepId === "create" @@ -435,6 +508,32 @@ const ShowcaseTakeover: FC = () => { Enter + ) : stepId === "notifications" ? ( + <> + {notificationsSupported ? ( + + ) : ( + + Not supported in this browser + + )} + + ) : ( <>