diff --git a/package.json b/package.json index 667188c0..05a650a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.90", + "version": "0.20.91", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/views/settings/notifications.view.js b/src/js/views/settings/notifications.view.js index ab735c86..7c0fdb2b 100644 --- a/src/js/views/settings/notifications.view.js +++ b/src/js/views/settings/notifications.view.js @@ -32,6 +32,7 @@ export default async function settingsNotificationsView({ desktopNotificationService?.isEnabled ?? false, ); state.$pushBusy = new Signal.State(false); + state.$pushCompleting = new Signal.State(false); async function handleToggle(checked) { if (!desktopNotificationService) return; @@ -77,15 +78,14 @@ export default async function settingsNotificationsView({ } async function startPushEnableFlow() { - let permission = null; - const choice = await choiceModal( + await choiceModal( "You'll be sent to the notification service to authorize push notifications. Message previews require additional read-only access to chat messages.", { title: "Enable push notifications?", choices: [ { value: "with-previews", - label: "Enable", + label: "Enable with message previews", style: "primary", }, { @@ -97,12 +97,16 @@ export default async function settingsNotificationsView({ ], onChoose: (value) => { if (value === "cancel") return null; - permission = Notification.requestPermission(); - return permission; + const permission = Notification.requestPermission(); + return handOffToPushService(permission, { + chatPreviews: value === "with-previews", + }); }, }, ); - if (choice === null || choice === "cancel") return; + } + + async function handOffToPushService(permission, { chatPreviews }) { if ((await permission) !== "granted") { showToast( "Notifications are blocked for this site. Re-enable them in your browser's site settings.", @@ -111,14 +115,13 @@ export default async function settingsNotificationsView({ return; } try { - await pushNotificationService.startEnableFlow({ - chatPreviews: choice === "with-previews", - }); + await pushNotificationService.startEnableFlow({ chatPreviews }); } catch (error) { console.error(error); showToast("Couldn't reach the notification service.", { style: "error", }); + throw error; } } @@ -133,6 +136,7 @@ export default async function settingsNotificationsView({ return; } state.$pushBusy.set(true); + state.$pushCompleting.set(true); try { await pushNotificationService.completeEnableFlow(); showToast("Push notifications enabled.", { style: "success" }); @@ -145,6 +149,7 @@ export default async function settingsNotificationsView({ showToast(message, { style: "error" }); } finally { state.$pushBusy.set(false); + state.$pushCompleting.set(false); } })(); @@ -167,6 +172,7 @@ export default async function settingsNotificationsView({ } const pushEnabled = pushNotificationService?.isEnabled ?? false; + const pushChecked = pushEnabled || state.$pushCompleting.get(); const pushBusy = state.$pushBusy.get(); const pushSupported = pushNotificationService?.isSupported ?? false; const pushRequiresInstall = @@ -236,7 +242,7 @@ export default async function settingsNotificationsView({ handlePushToggle(event.detail.checked)} > diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 52165e66..8a193da4 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -135,6 +135,7 @@ export class MockServer { this.notificationServiceUnreachable = false; // Which notification service this device has chosen; null means none. this.notificationServiceDid = null; + this.notificationServiceLookupHeld = null; this.registerPushCalls = []; this.unregisterPushCalls = []; this.registerPushStatus = 200; @@ -202,6 +203,16 @@ export class MockServer { this.notificationServiceUnreachable = true; } + // Stall the dummy notification service's DID lookup until the returned + // function is called, to observe the UI mid-request. Call before setup(). + holdNotificationServiceLookup() { + let release; + this.notificationServiceLookupHeld = new Promise((resolve) => { + release = resolve; + }); + return release; + } + // Start with a notification service already chosen on this device, as if a // previous launch had left it there. Call before setup(). setNotificationServiceDid(did) { @@ -1040,7 +1051,8 @@ export class MockServer { // document, so both are served here. await page.route( `${notificationService.endpoint}/.well-known/did.json`, - (route) => { + async (route) => { + await this.notificationServiceLookupHeld; if (this.notificationServiceUnreachable) { route.fulfill({ status: 500, body: "Server Error" }); return; diff --git a/tests/e2e/specs/views/settingsNotifications.view.test.js b/tests/e2e/specs/views/settingsNotifications.view.test.js index 2999c411..4361c8ed 100644 --- a/tests/e2e/specs/views/settingsNotifications.view.test.js +++ b/tests/e2e/specs/views/settingsNotifications.view.test.js @@ -326,6 +326,36 @@ test.describe("Settings > Notifications view", () => { expect(new URL(page.url()).searchParams.get("chat_previews")).toBe("1"); }); + test("the chosen option shows a spinner until the handoff", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setNotificationServiceDid(notificationService.did); + await mockServer.setup(page); + await stubNotificationPermission(page, { initial: "default" }); + await login(page); + await page.goto("/settings/notifications"); + + const toggle = page.locator('[data-testid="push-notifications-toggle"]'); + await expect(toggle).not.toHaveAttribute("disabled", "", { + timeout: 10000, + }); + const releaseLookup = mockServer.holdNotificationServiceLookup(); + await toggle.click(); + const choice = page.locator( + '[data-testid="choice-modal"] [data-testid="modal-choice-without-previews"]', + ); + await choice.click(); + + await expect(choice).toHaveAttribute("data-teststate", "pending"); + releaseLookup(); + await expect(page).toHaveURL( + new RegExp( + notificationService.authUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ), + ); + }); + test("cancelling the prompt asks for no permission", async ({ page }) => { const mockServer = new MockServer(); mockServer.setNotificationServiceDid(notificationService.did); @@ -377,6 +407,70 @@ test.describe("Settings > Notifications view", () => { await expect(page).toHaveURL(/\/settings\/notifications$/); }); + test("returning from the service shows the toggle on while setup finishes", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setNotificationServiceDid(notificationService.did); + const releaseLookup = mockServer.holdNotificationServiceLookup(); + await mockServer.setup(page); + await stubNotificationPermission(page, { initial: "granted" }); + await login(page); + await page.goto("/settings/notifications?chat_previews=0"); + + const toggle = page.locator('[data-testid="push-notifications-toggle"]'); + await expect(toggle).toHaveAttribute("checked", "", { timeout: 10000 }); + await expect(toggle).toHaveAttribute("disabled", ""); + releaseLookup(); + }); + + test("returning from the service unchecks the toggle if setup fails", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setNotificationServiceDid(notificationService.did); + mockServer.failNotificationServiceLookup(); + const releaseLookup = mockServer.holdNotificationServiceLookup(); + await mockServer.setup(page); + await stubNotificationPermission(page, { initial: "granted" }); + await login(page); + await page.goto("/settings/notifications?chat_previews=0"); + + const toggle = page.locator('[data-testid="push-notifications-toggle"]'); + await expect(toggle).toHaveAttribute("checked", "", { timeout: 10000 }); + releaseLookup(); + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + await expect(toggle).not.toHaveAttribute("checked", ""); + await expect(toggle).not.toHaveAttribute("disabled", ""); + }); + + test("an unreachable service keeps the prompt open to retry", async ({ + page, + }) => { + const mockServer = new MockServer(); + mockServer.setNotificationServiceDid(notificationService.did); + mockServer.failNotificationServiceLookup(); + await mockServer.setup(page); + await stubNotificationPermission(page, { initial: "granted" }); + await login(page); + await page.goto("/settings/notifications"); + + const toggle = page.locator('[data-testid="push-notifications-toggle"]'); + await expect(toggle).not.toHaveAttribute("disabled", "", { + timeout: 10000, + }); + await toggle.click(); + const choice = page.locator( + '[data-testid="choice-modal"] [data-testid="modal-choice-without-previews"]', + ); + await choice.click(); + + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + await expect(choice).toHaveAttribute("data-teststate", "idle"); + await expect(choice).toBeEnabled(); + await expect(page).toHaveURL(/\/settings\/notifications$/); + }); + test("turning push off clears the stored preference", async ({ page }) => { const mockServer = new MockServer(); mockServer.setNotificationServiceDid(notificationService.did);