ReviewOS

also looking at this

craft-native/craft

fix(sdk): schedule a notification for its scheduleAt, not immediately

#263
Merged glennmichael123 wants to merge fix/ios-notification-schedule-at into main
3 files +73 -3
packages/typescript/src/__tests__/mobile.test.tsmodified+48-1
Changes to packages/typescript/src/__tests__/mobile.test.ts
@@ -24,7 +24,7 @@ import type {
2424 LiveActivityHandle,
2525 LiveActivityOptions,
2626} from '../api/mobile'
27import { normalizeDeepLinkURL, pushNotifications, watchConnectivity } from '../api/mobile'
27import { bridgeNotification, normalizeDeepLinkURL, notifications, pushNotifications, watchConnectivity } from '../api/mobile'
2828
2929describe('Mobile deep links', () => {
3030 it('normalizes native payloads to the public string contract', () => {
@@ -70,6 +70,53 @@ describe('Mobile notification taps', () => {
7070 })
7171})
7272
73describe('Mobile scheduled notifications', () => {
74 // #261: the bridges schedule from `delay`, and neither reads `scheduleAt`,
75 // so a reminder meant for an hour from now fired at once.
76 const NOW = 1_700_000_000_000
77
78 it('turns scheduleAt into a delay from now, and sends no scheduleAt', () => {
79 const sent = bridgeNotification({ title: 'Reminder', body: 'Break', scheduleAt: NOW + 3_600_000, data: { screen: 'plant-id' } }, NOW)
80 expect(sent).toEqual({ title: 'Reminder', body: 'Break', delay: 3_600_000, data: { screen: 'plant-id' } })
81 expect('scheduleAt' in sent).toBe(false)
82 })
83
84 it('delivers now for a moment already here or past, without a zero delay', () => {
85 // A zero delay raises inside UserNotifications on the Swift side, so
86 // "now" is no delay at all, which both bridges deliver immediately.
87 for (const scheduleAt of [NOW, NOW - 5_000]) {
88 const sent = bridgeNotification({ title: 'Now', scheduleAt }, NOW)
89 expect(sent).toEqual({ title: 'Now' })
90 }
91 // A fraction of a millisecond still waits: Android reads whole ms.
92 expect(bridgeNotification({ title: 'Soon', scheduleAt: NOW + 0.4 }, NOW)).toEqual({ title: 'Soon', delay: 1 })
93 })
94
95 it('leaves a notification with no scheduleAt as it was', () => {
96 expect(bridgeNotification({ title: 'Plain', badge: 2 }, NOW)).toEqual({ title: 'Plain', badge: 2 })
97 })
98
99 it('schedules through the bridge with the delay, not scheduleAt', async () => {
100 const previousWindow = (globalThis as any).window
101 const sent: unknown[] = []
102 ;(globalThis as any).window = { craft: { notifications: { schedule: async (options: unknown) => { sent.push(options) } } } }
103 try {
104 const before = Date.now()
105 await notifications.schedule({ title: 'Reminder', scheduleAt: before + 3_600_000 })
106 const after = Date.now()
107 expect(sent).toHaveLength(1)
108 const options = sent[0] as { delay?: number, scheduleAt?: number }
109 expect(options.scheduleAt).toBeUndefined()
110 expect(options.delay).toBeGreaterThanOrEqual(3_600_000 - (after - before))
111 expect(options.delay).toBeLessThanOrEqual(3_600_000)
112 }
113 finally {
114 if (previousWindow === undefined) delete (globalThis as any).window
115 else (globalThis as any).window = previousWindow
116 }
117 })
118})
119
73120describe('Mobile notification arrivals', () => {
74121 // #256: the bridge's onReceive, passed through, and the bare event when a
75122 // bridge predates it.
packages/typescript/src/api/mobile.tsmodified+23-2
Changes to packages/typescript/src/api/mobile.ts
@@ -1179,6 +1179,27 @@ export interface NotificationOptions {
11791179 scheduleAt?: number
11801180}
11811181
1182/** A notification as the bridges read it: `delay` in place of `scheduleAt`. */
1183export type BridgeNotification = Omit<NotificationOptions, 'scheduleAt'> & { delay?: number }
1184
1185/**
1186 * The bridges' spelling of a notification: `scheduleAt` becomes `delay`.
1187 *
1188 * Both bridges schedule from `delay`, milliseconds from now, and iOS also from
1189 * `timestamp`; neither reads `scheduleAt`, so a reminder meant for an hour
1190 * from now fired at once (#261). `delay` rather than `timestamp` because
1191 * Android reads only `delay`, and because an interval is what an instant
1192 * means, where iOS's calendar trigger would move with the device's time zone.
1193 * A moment already past sends no delay at all, which both bridges deliver
1194 * now: a zero delay raises inside UserNotifications on the Swift side.
1195 */
1196export function bridgeNotification(options: NotificationOptions, now: number = Date.now()): BridgeNotification {
1197 const { scheduleAt, ...rest } = options
1198 if (scheduleAt === undefined) return rest
1199 const wait = Math.ceil(scheduleAt - now)
1200 return wait > 0 ? { ...rest, delay: wait } : rest
1201}
1202
11821203/**
11831204 * Local notifications API.
11841205 *
@@ -1226,7 +1247,7 @@ export const notifications = {
12261247 async schedule(options: NotificationOptions): Promise<void> {
12271248 const craft = getCraftMobile()
12281249 if (typeof window !== 'undefined' && craft?.notifications) {
1229 return craft.notifications.schedule(options)
1250 return craft.notifications.schedule(bridgeNotification(options))
12301251 }
12311252 throw new Error('Scheduled notifications not available in web')
12321253 },
@@ -1637,7 +1658,7 @@ interface CraftMobileBridge {
16371658 }
16381659 notifications?: {
16391660 show(options: NotificationOptions): Promise<void>
1640 schedule(options: NotificationOptions): Promise<void>
1661 schedule(options: BridgeNotification): Promise<void>
16411662 cancelAll(): Promise<void>
16421663 setBadge(count: number): Promise<void>
16431664 }
packages/typescript/types/craft.d.tsmodified+2-0
Changes to packages/typescript/types/craft.d.ts
@@ -1258,6 +1258,8 @@ export interface NotificationData {
12581258 badge?: number;
12591259 timestamp?: number;
12601260 delay?: number;
1261 /** Handed back to the page when the notification is tapped or arrives. */
1262 data?: Record<string, any>;
12611263}
12621264
12631265export interface PendingNotification {