ReviewOS

also looking at this

craft-native/craft

feat(ios): tell the page about a notification that arrives while the app is open

#259
Merged glennmichael123 wants to merge feat/ios-notification-received into main
11 files +410 -58

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/ios/src/index.test.tsmodified+17-0
Changes to packages/ios/src/index.test.ts
@@ -376,6 +376,23 @@ describe('Craft iOS builder', () => {
376376 expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationResponse"')
377377 })
378378
379 it('tells the page about a notification that arrives while the app is open', async () => {
380 // willPresent used to show the banner and nothing else, so a page on
381 // screen when a push landed heard about it only if someone tapped it.
382 const output = mkdtempSync(join(tmpdir(), 'craft-ios-notification-received-'))
383 await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output })
384 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
385
386 const willPresent = swift.slice(swift.indexOf('willPresent notification: UNNotification'))
387 const body = willPresent.slice(0, willPresent.indexOf('\n }\n'))
388 expect(body).toContain('CraftEventManager.shared.handleNotificationReceived(notification.request.content.userInfo)')
389 // Told, not asked: the banner still shows.
390 expect(body).toContain('completionHandler([.banner, .badge, .sound])')
391
392 const handle = swift.slice(swift.indexOf('func handleNotificationReceived('))
393 expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationReceived"')
394 })
395
379396 it('settles calendar callbacks only after a real EventKit operation', async () => {
380397 const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-'))
381398 await init({
packages/ios/src/page-script.test.tsmodified+44-0
Changes to packages/ios/src/page-script.test.ts
@@ -94,6 +94,8 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE
9494 // What CraftEventManager.handleNotificationResponse evaluates.
9595 tap: (detail: unknown) =>
9696 page.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail })),
97 receive: (detail: unknown) =>
98 page.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail })),
9799 }
98100}
99101
@@ -253,6 +255,48 @@ describe('the injected iOS page script', () => {
253255 expect(seen).toEqual([])
254256 })
255257
258 // #256: a notification that arrives while the page is open reaches it,
259 // live, on the notifications object the page ends up with.
260 it('hands a notification that arrived while the page was open to onReceive', () => {
261 const page = loadPage()
262 expect(typeof page.craft.notifications.onReceive).toBe('function')
263
264 const seen: unknown[] = []
265 const unsubscribe = page.craft.notifications.onReceive((detail: unknown) => seen.push(detail))
266 page.receive({ screen: 'recap' })
267 expect(seen).toEqual([{ screen: 'recap' }])
268
269 unsubscribe()
270 page.receive({ screen: 'plant-id' })
271 expect(seen).toEqual([{ screen: 'recap' }])
272 })
273
274 it('keeps arrivals and taps apart', async () => {
275 const page = loadPage()
276 const taps: unknown[] = []
277 const arrivals: unknown[] = []
278 page.craft.notifications.onTap((detail: unknown) => taps.push(detail))
279 page.craft.notifications.onReceive((detail: unknown) => arrivals.push(detail))
280 await tick()
281
282 page.receive({ screen: 'recap' })
283 page.tap({ screen: 'plant-id' })
284 expect(arrivals).toEqual([{ screen: 'recap' }])
285 expect(taps).toEqual([{ screen: 'plant-id' }])
286 })
287
288 // Held taps are for a launch the page was not there for. An arrival is
289 // news only to a page that is running, so nothing is held for later.
290 it('does not hold an arrival for a page that subscribes afterwards', async () => {
291 const page = loadPage()
292 page.receive({ screen: 'recap' })
293
294 const seen: unknown[] = []
295 page.craft.notifications.onReceive((detail: unknown) => seen.push(detail))
296 await tick()
297 expect(seen).toEqual([])
298 })
299
256300 // #198 and #215. The Android page script runs the same block; its own test
257301 // covers the rest of the contract.
258302 const tick = () => new Promise(resolve => setTimeout(resolve, 5))
packages/ios/templates/CraftApp.swiftmodified+27-1
Changes to packages/ios/templates/CraftApp.swift
@@ -115,6 +115,11 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
115115 }
116116
117117 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
118 // Told to the page as well as shown. A page that is open when a push
119 // lands used to hear about it only if someone tapped the banner, so it
120 // could not refresh what the push was about while it was on screen.
121 // The banner still shows: the page is told, not asked.
122 CraftEventManager.shared.handleNotificationReceived(notification.request.content.userInfo)
118123 completionHandler([.banner, .badge, .sound])
119124 }
120125}
@@ -156,12 +161,22 @@ class CraftEventManager {
156161 /// event: an APNs payload's keys always are, and the page could not index
157162 /// by anything else anyway.
158163 func handleNotificationResponse(_ userInfo: [AnyHashable: Any]) {
164 sendToWeb("craftNotificationResponse", data: Self.pageData(userInfo))
165 }
166
167 /// A notification that arrived while the app was in front, in the same
168 /// shape as a tap so one handler can read both.
169 func handleNotificationReceived(_ userInfo: [AnyHashable: Any]) {
170 sendToWeb("craftNotificationReceived", data: Self.pageData(userInfo))
171 }
172
173 private static func pageData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
159174 var data: [String: Any] = [:]
160175 for (key, value) in userInfo {
161176 guard let key = key as? String else { continue }
162177 data[key] = value
163178 }
164 sendToWeb("craftNotificationResponse", data: data)
179 return data
165180 }
166181
167182 func handleSiriActivity(_ activity: NSUserActivity) -> Bool {
@@ -3149,6 +3164,17 @@ struct CraftWebView: UIViewRepresentable {
31493164 craft.notifications.onTap = function(callback) {
31503165 return craft._subscribeNotificationTaps(callback);
31513166 };
3167 // A notification that arrived while the page was open.
3168 // Live only, with nothing held: it matters to a page that
3169 // is running, and one that subscribes later has no use for
3170 // an arrival it was not there to see.
3171 craft.notifications.onReceive = function(callback) {
3172 var listener = function(e) { callback(e.detail); };
3173 window.addEventListener('craftNotificationReceived', listener);
3174 return function() {
3175 window.removeEventListener('craftNotificationReceived', listener);
3176 };
3177 };
31523178 }
31533179 })(window.craft);
31543180
packages/typescript/src/__tests__/mobile.test.tsmodified+35-0
Changes to packages/typescript/src/__tests__/mobile.test.ts
@@ -70,6 +70,41 @@ describe('Mobile notification taps', () => {
7070 })
7171})
7272
73describe('Mobile notification arrivals', () => {
74 // #256: the bridge's onReceive, passed through, and the bare event when a
75 // bridge predates it.
76 it('subscribes through the bridge when it offers onReceive', () => {
77 const previousWindow = (globalThis as any).window
78 const unsubscribe = () => {}
79 let handed: ((detail: unknown) => void) | undefined
80 ;(globalThis as any).window = {
81 craft: { notifications: { onReceive: (callback: (detail: unknown) => void) => { handed = callback; return unsubscribe } } },
82 }
83
84 try {
85 const seen: unknown[] = []
86 expect(pushNotifications.onReceive(data => seen.push(data))).toBe(unsubscribe)
87 handed?.({ screen: 'recap' })
88 handed?.(undefined)
89 expect(seen).toEqual([{ screen: 'recap' }, {}])
90 }
91 finally {
92 if (previousWindow === undefined) delete (globalThis as any).window
93 else (globalThis as any).window = previousWindow
94 }
95 })
96
97 it('listens for craftNotificationReceived when the bridge has no onReceive', () => {
98 const seen: unknown[] = []
99 const unsubscribe = pushNotifications.onReceive(data => seen.push(data))
100 globalThis.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail: { screen: 'recap' } }))
101 globalThis.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail: { screen: 'tapped' } }))
102 unsubscribe()
103 globalThis.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail: { screen: 'late' } }))
104 expect(seen).toEqual([{ screen: 'recap' }])
105 })
106})
107
73108describe('Mobile Android bridge promises', () => {
74109 it('reads the typed watch reachability envelope', async () => {
75110 const previousWindow = (globalThis as any).window
packages/typescript/src/api/mobile.tsmodified+17-0
Changes to packages/typescript/src/api/mobile.ts
@@ -1375,6 +1375,23 @@ export const pushNotifications = {
13751375 }) ?? (() => {})
13761376 }
13771377 return onCraftEvent('craftNotificationResponse', detail => callback(detail))
1378 },
1379 /**
1380 * A notification that arrived while the app was open, with the payload it
1381 * was sent with — the same shape `onNotification` hands a tap.
1382 *
1383 * The banner still shows. This is for a page that wants to react while it
1384 * is on screen, such as refreshing what the push was about. Live only: an
1385 * arrival before the page subscribed is not held for it. iOS only for now.
1386 */
1387 onReceive(callback: (data: Record<string, unknown>) => void): () => void {
1388 const craft = getCraftRoot()
1389 if (craft?.notifications?.onReceive) {
1390 return craft.notifications.onReceive((detail: unknown) => {
1391 callback((detail ?? {}) as Record<string, unknown>)
1392 }) ?? (() => {})
1393 }
1394 return onCraftEvent('craftNotificationReceived', detail => callback(detail))
13781395 }
13791396}
13801397