also looking at this
feat(ios): tell the page about a notification that arrives while the app is open
#259
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.
| @@ -376,6 +376,23 @@ describe('Craft iOS builder', () => { | ||
| 376 | 376 | expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationResponse"') |
| 377 | 377 | }) |
| 378 | 378 | |
| 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 | ||
| 379 | 396 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 380 | 397 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 381 | 398 | await init({ |
| @@ -94,6 +94,8 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE | ||
| 94 | 94 | // What CraftEventManager.handleNotificationResponse evaluates. |
| 95 | 95 | tap: (detail: unknown) => |
| 96 | 96 | page.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail })), |
| 97 | receive: (detail: unknown) => | |
| 98 | page.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail })), | |
| 97 | 99 | } |
| 98 | 100 | } |
| 99 | 101 | |
| @@ -253,6 +255,48 @@ describe('the injected iOS page script', () => { | ||
| 253 | 255 | expect(seen).toEqual([]) |
| 254 | 256 | }) |
| 255 | 257 | |
| 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 | ||
| 256 | 300 | // #198 and #215. The Android page script runs the same block; its own test |
| 257 | 301 | // covers the rest of the contract. |
| 258 | 302 | const tick = () => new Promise(resolve => setTimeout(resolve, 5)) |
| @@ -115,6 +115,11 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio | ||
| 115 | 115 | } |
| 116 | 116 | |
| 117 | 117 | 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) | |
| 118 | 123 | completionHandler([.banner, .badge, .sound]) |
| 119 | 124 | } |
| 120 | 125 | } |
| @@ -156,12 +161,22 @@ class CraftEventManager { | ||
| 156 | 161 | /// event: an APNs payload's keys always are, and the page could not index |
| 157 | 162 | /// by anything else anyway. |
| 158 | 163 | 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] { | |
| 159 | 174 | var data: [String: Any] = [:] |
| 160 | 175 | for (key, value) in userInfo { |
| 161 | 176 | guard let key = key as? String else { continue } |
| 162 | 177 | data[key] = value |
| 163 | 178 | } |
| 164 | sendToWeb("craftNotificationResponse", data: data) | |
| 179 | return data | |
| 165 | 180 | } |
| 166 | 181 | |
| 167 | 182 | func handleSiriActivity(_ activity: NSUserActivity) -> Bool { |
| @@ -3149,6 +3164,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3149 | 3164 | craft.notifications.onTap = function(callback) { |
| 3150 | 3165 | return craft._subscribeNotificationTaps(callback); |
| 3151 | 3166 | }; |
| 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 | }; | |
| 3152 | 3178 | } |
| 3153 | 3179 | })(window.craft); |
| 3154 | 3180 | |
| @@ -70,6 +70,41 @@ describe('Mobile notification taps', () => { | ||
| 70 | 70 | }) |
| 71 | 71 | }) |
| 72 | 72 | |
| 73 | describe('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 | ||
| 73 | 108 | describe('Mobile Android bridge promises', () => { |
| 74 | 109 | it('reads the typed watch reachability envelope', async () => { |
| 75 | 110 | const previousWindow = (globalThis as any).window |
| @@ -1375,6 +1375,23 @@ export const pushNotifications = { | ||
| 1375 | 1375 | }) ?? (() => {}) |
| 1376 | 1376 | } |
| 1377 | 1377 | 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)) | |
| 1378 | 1395 | } |
| 1379 | 1396 | } |
| 1380 | 1397 | |