diff --git a/web/src/home/RotationCheck.test.tsx b/web/src/home/RotationCheck.test.tsx index 927df88..152498e 100644 --- a/web/src/home/RotationCheck.test.tsx +++ b/web/src/home/RotationCheck.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createMemoryRouter, RouterProvider } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' -import { clickPath, parsePushPayload } from '../push/notification.ts' +import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts' import { QueryClientProvider } from '@tanstack/react-query' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' @@ -19,6 +19,7 @@ const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date afterEach(() => { vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'serviceWorker') }) type Deferred = { resolve: (response: Response) => void; path: string; body: unknown } @@ -53,7 +54,7 @@ function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) { throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) }) vi.stubGlobal('fetch', fetchMock) - return { fetchMock, decisions } + return { fetchMock, decisions, server } } // decided is the server's answer to a decision on review. @@ -244,4 +245,37 @@ describe('RotationCheck', () => { await renderHome(path) expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() }) + + it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => { + // navigator.serviceWorker is where the worker's postMessage to this + // window arrives. + const container = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + const { fetchMock, server } = stubApi([]) + const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length + await renderHome('/') + const home = await screen.findByRole('heading', { name: 'Pizza night' }) + await waitFor(() => expect(reads()).toBe(1)) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + + // Within the cached list's staleTime the Rotation check comes due and + // its notification is tapped while Home is open at /. + server.push(SHAKSHUKA) + const origin = 'https://mealquest.test' + const win = { + url: `${origin}/`, + focused: true, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })), + } satisfies ClickWindow + await act(() => + openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin), + ) + + expect(win.navigate).not.toHaveBeenCalled() + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(reads()).toBe(2) + expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home) + }) }) diff --git a/web/src/home/rotation.ts b/web/src/home/rotation.ts index ac5416c..27375ae 100644 --- a/web/src/home/rotation.ts +++ b/web/src/home/rotation.ts @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { photoId } from '../dinners/api.ts' +import { isNotificationOpened } from '../push/notification.ts' export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews' export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const @@ -43,16 +45,29 @@ export function parseRotationReviews(where: string, body: unknown): RotationRevi // useRotationReviews is the pending Rotation checks (GET // /api/v1/rotation-reviews), oldest plan date first. Each mount of Home -// reads them afresh, so Home opened from a rotation_review notification -// shows the check it is about. +// reads them afresh, and so does a notification tap that focuses Home +// already open (the service worker's NotificationOpened), so Home opened +// from a rotation_review notification shows the check it is about. export function useRotationReviews() { const { apiFetch } = useApi() - return useQuery({ + const query = useQuery({ queryKey: ROTATION_REVIEWS_KEY, queryFn: async () => parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch(ROTATION_REVIEWS_PATH)), refetchOnMount: 'always', }) + const { refetch } = query + useEffect(() => { + // Without a service worker there are no notifications to tap. + if (!('serviceWorker' in navigator)) return + const container = navigator.serviceWorker + const onMessage = (event: MessageEvent) => { + if (isNotificationOpened(event.data)) void refetch() + } + container.addEventListener('message', onMessage) + return () => container.removeEventListener('message', onMessage) + }, [refetch]) + return query } // useDecideRotationReview answers "Keep it in rotation?" for a pending diff --git a/web/src/push/notification.test.ts b/web/src/push/notification.test.ts index 2c432ae..c7061b6 100644 --- a/web/src/push/notification.test.ts +++ b/web/src/push/notification.test.ts @@ -5,6 +5,7 @@ import { GENERIC_NOTICE, appPath, clickPath, + isNotificationOpened, openNotification, parsePushPayload, planClick, @@ -178,6 +179,7 @@ describe('openNotification', () => { focused, focus: vi.fn(async () => {}), navigate: vi.fn(async () => {}), + postMessage: vi.fn(), } satisfies ClickWindow } @@ -193,6 +195,18 @@ describe('openNotification', () => { expect(win.focus).toHaveBeenCalled() expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`) expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!) + expect(win.postMessage).not.toHaveBeenCalled() + expect(all.openWindow).not.toHaveBeenCalled() + }) + + it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => { + const win = window('/', true) + const all = clients([win]) + await openNotification({ url: '/' }, all, origin) + expect(win.focus).toHaveBeenCalled() + expect(win.navigate).not.toHaveBeenCalled() + expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` }) + expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true) expect(all.openWindow).not.toHaveBeenCalled() }) diff --git a/web/src/push/notification.ts b/web/src/push/notification.ts index b86c9c4..b77daf6 100644 --- a/web/src/push/notification.ts +++ b/web/src/push/notification.ts @@ -150,6 +150,18 @@ export function planClick(windows: readonly W[], path: stri export type ClickWindow = AppWindow & { focus(): Promise navigate(url: string): Promise + postMessage(message: NotificationOpened): void +} + +// NotificationOpened is the message a tap posts to an open window it focuses +// without navigating, because the window is already at url: nothing remounts +// there, so the screen reads afresh what the notification is about. +export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string } + +// isNotificationOpened reports whether a message from the service worker is +// NotificationOpened. +export function isNotificationOpened(message: unknown): message is NotificationOpened { + return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string' } export type ClickClients = { @@ -158,7 +170,8 @@ export type ClickClients = { } // openNotification carries out a tap on a notification with data: focus -// first, while the tap still allows it, then navigate. +// first, while the tap still allows it, then navigate; a window already at +// the url is told NotificationOpened instead. export async function openNotification( data: unknown, clients: ClickClients, @@ -171,4 +184,5 @@ export async function openNotification( } await plan.window.focus() if (plan.navigate) await plan.window.navigate(plan.url) + else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url }) }