From 4fc04be2a0b0ec32b7f356126a7a8c7620ba5806 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 13:32:23 +0100 Subject: [PATCH 1/3] fix(native): route push notification taps inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a push left the app: OneSignal fires its own ACTION_VIEW/openURL for the notification's launch URL, so Android only re-entered the app for paths in the App Links filter, and iOS — which won't re-enter an app for its own universal link — always bounced to Safari. Suppress launch URLs on both platforms and route the tap from the click listener in useNativePlugins, reusing the App Links deep-link path. That covers destinations outside the intent filter and doesn't depend on link verification. deepLinkToNativePath now accepts a bare path (push payloads carry one), rejects off-domain links instead of rewriting them, and maps two destinations that had no native route: /receipt/ and /?chargeId=. /receipt/[entryId] is a server component stripped from the static export, so add a client twin at (mobile-ui)/receipt reading ?id=&kind= — receipts are the most common push destination and previously 404'd in-app. Also widen App Links to /history, /rewards, /badges and /profile (email CTA targets that still open the browser today), and map absolute peanut.me hrefs in the in-app inbox back to in-app paths. --- android/app/src/main/AndroidManifest.xml | 17 ++++++ ios/App/App/Info.plist | 6 ++ src/app/(mobile-ui)/notifications/page.tsx | 7 +++ src/app/(mobile-ui)/receipt/page.tsx | 65 ++++++++++++++++++++++ src/hooks/useNativePlugins.ts | 18 ++++++ src/utils/__tests__/native-routes.test.ts | 59 ++++++++++++++++++++ src/utils/native-routes.ts | 43 +++++++++++--- 7 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/app/(mobile-ui)/receipt/page.tsx diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9932357b4..aa03f6dede 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,14 @@ + + + + + + + + @@ -78,6 +86,15 @@ + + + + OneSignal_suppress_launch_urls + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index a471b20eb1..51cfdacb63 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -7,6 +7,7 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' +import { deepLinkToNativePath } from '@/utils/native-routes' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' import { PEANUTMAN } from '@/assets' @@ -157,7 +158,13 @@ export default function NotificationsPage() { if (group.items.length === 1) position = 'single' else if (idx === 0) position = 'first' else if (idx === group.items.length - 1) position = 'last' + // Rows minted from the OneSignal-dashboard webhook store the + // operator's `url` verbatim, so an absolute peanut.me link would + // navigate the webview off-app. Map those back to an in-app path; + // genuinely external links fall through untouched. const href = notif.ctaDeeplink + ? (deepLinkToNativePath(notif.ctaDeeplink) ?? notif.ctaDeeplink) + : notif.ctaDeeplink return ( ?kind=X onto this route's query params. +export default function NativeReceiptPage() { + const router = useRouter() + const searchParams = useSearchParams() + const entryId = searchParams.get('id') + const kind = resolveReceiptKind(searchParams.get('kind') ?? undefined, searchParams.get('t') ?? undefined) + + const { + data: entry, + isLoading, + isError, + } = useQuery({ + queryKey: [TRANSACTIONS, 'entry', entryId, kind], + enabled: Boolean(entryId && kind), + queryFn: async (): Promise => { + const response = await apiFetch(`/history/${encodeURIComponent(entryId!)}?kind=${kind}`) + if (!response.ok) throw new Error(`Failed to fetch history entry: ${response.status}`) + return completeHistoryEntry(await response.json()) + }, + // A pending transaction can still settle while the receipt is open. + refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), + }) + + useEffect(() => { + if (!entryId || !kind || isError) router.replace('/home') + }, [entryId, kind, isError, router]) + + if (!entryId || !kind || isError) return null + + return ( + +
+ +
+
+ {isLoading || !entry ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..075baece92 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { isCapacitor, getPlatform } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' +import { getOneSignalAdapter } from '@/services/onesignal' /** * initializes capacitor native plugins (back button, status bar, splash screen). @@ -50,6 +51,23 @@ export function useNativePlugins() { console.warn('failed to init app listeners:', e) } + try { + // Push taps: the OneSignal SDKs are configured not to open the + // launch URL themselves (suppressLaunchURLs / OneSignal_suppress_launch_urls), + // so routing is ours. `additionalData.deepLink` is the canonical + // relative path the API sends; the launch URL is the fallback for + // notifications sent before that field existed. + const adapter = await getOneSignalAdapter() + cleanups.push( + adapter.onNotificationClick(({ deepLink, additionalData }) => { + const target = additionalData.deepLink + openDeepLink(typeof target === 'string' ? target : deepLink) + }) + ) + } catch (e) { + console.warn('failed to init notification click listener:', e) + } + try { const { StatusBar, Style } = await import('@capacitor/status-bar') await StatusBar.setOverlaysWebView({ overlay: false }) diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2f995266fc..35c78d7baa 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -20,6 +20,7 @@ import { withdrawCountryUrl, withdrawBankUrl, rewriteMethodPath, + deepLinkToNativePath, } from '../native-routes' describe('native-routes', () => { @@ -290,4 +291,62 @@ describe('native-routes', () => { }) }) }) + + describe('deepLinkToNativePath', () => { + describe('capacitor mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(true) + }) + + it('accepts a bare path — push payloads carry the deep link without a host', () => { + expect(deepLinkToNativePath('/receipt/intent-1?kind=ONRAMP')).toBe('/receipt?id=intent-1&kind=ONRAMP') + }) + + it('accepts a full App-Links url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt?id=intent-1&kind=ONRAMP' + ) + }) + + it('maps a charge deep link onto the pay-request stand-in for the disabled catch-all route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/pay-request?chargeId=charge-123') + }) + + it('leaves a static in-app route untouched', () => { + expect(deepLinkToNativePath('https://peanut.me/history')).toBe('/history') + }) + + it('still rewrites dynamic routes to their query-param form', () => { + expect(deepLinkToNativePath('https://peanut.me/send/bob')).toBe('/send?recipient=bob') + expect(deepLinkToNativePath('https://peanut.me/qr/abc123')).toBe('/qr?code=abc123') + expect(deepLinkToNativePath('https://peanut.me/withdraw/be/bank')).toBe( + '/withdraw?country=be&view=bank' + ) + }) + + it('rejects an off-domain url rather than rewriting it into an in-app path', () => { + expect(deepLinkToNativePath('https://evil.com/receipt/intent-1')).toBeNull() + }) + + it('returns null for an unparseable link', () => { + expect(deepLinkToNativePath('http://')).toBeNull() + }) + }) + + describe('web mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(false) + }) + + it('keeps the path-based receipt url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt/intent-1?kind=ONRAMP' + ) + }) + + it('keeps a profile charge link on the profile route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/alice?chargeId=charge-123') + }) + }) + }) }) diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 95e118a822..00cb79f554 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -4,6 +4,12 @@ import { isCapacitor } from './capacitor' +// Deep links are peanut.me links by definition — that's the host the Android +// App Links filter and the AASA are bound to. Deliberately not derived from +// NEXT_PUBLIC_BASE_URL: a build pointed at a preview origin must still route a +// real peanut.me notification link. +const APP_HOSTS = /^(.+\.)?peanut\.me$/ + export function profileUrl(username: string): string { return isCapacitor() ? `/send?recipient=${encodeURIComponent(username)}` : `/${username}` } @@ -64,21 +70,29 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri } /** - * Maps an incoming App-Links URL (https://peanut.me/) to the path the - * native static export can actually render. Dynamic web routes are funnelled - * through the same query-param helpers used to build outbound links, so - * `/send/` → `/send?recipient=`, `/qr/` → `/qr?code=`, - * `/add-money//bank` → `/add-money?country=&view=bank`, etc. - * Static routes (e.g. `/card`, `/pay-request`) pass through unchanged. - * Returns null for an unparseable URL. + * Maps an incoming deep link — an App-Links URL (https://peanut.me/) or a + * bare path from a push payload — to the path the native static export can + * actually render. Dynamic web routes are funnelled through the same query-param + * helpers used to build outbound links, so `/send/` → `/send?recipient=`, + * `/qr/` → `/qr?code=`, `/add-money//bank` → + * `/add-money?country=&view=bank`, etc. Static routes (e.g. `/card`, + * `/pay-request`) pass through unchanged. + * + * Returns null for an unparseable link or one pointing at another host — an + * off-domain URL must stay off-domain rather than be rewritten into a bogus + * in-app path. */ export function deepLinkToNativePath(url: string): string | null { let parsed: URL try { - parsed = new URL(url) + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + parsed = new URL(url, 'https://peanut.me') } catch { return null } + if (!APP_HOSTS.test(parsed.hostname)) return null + const path = parsed.pathname const extraParams = parsed.search.replace(/^\?/, '') const segments = path.split('/').filter(Boolean) @@ -96,6 +110,19 @@ export function deepLinkToNativePath(url: string): string | null { if (segments[0] === 'add-money' || segments[0] === 'withdraw') { return rewriteMethodPath(path, extraParams || undefined) } + // `/receipt/?kind=X` — the web receipt page is a server component and is + // stripped from the static export (scripts/native-build.js), so native routes + // to the client variant. `kind` rides along in extraParams. + if (segments[0] === 'receipt' && segments[1]) { + const id = decodeURIComponent(segments[1]) + return appendParams(isCapacitor() ? `/receipt?id=${encodeURIComponent(id)}` : path, extraParams) + } + // `/?chargeId=` — the catch-all profile route is disabled in + // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). + if (isCapacitor() && segments.length === 1) { + const chargeId = parsed.searchParams.get('chargeId') + if (chargeId) return chargePayUrl(chargeId) + } return appendParams(path, extraParams) } From cf4ba72027d9e27a70d4b01d8419ead60064be63 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 14:33:15 +0100 Subject: [PATCH 2/3] fix(native): harden deep-link mapping against malformed and reserved paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review: deepLinkToNativePath only guarded the URL parse, but decodeURIComponent throws URIError on a stray '%'. This PR made the function reachable from a render path (the notifications list calls it inside .map), so one malformed ctaDeeplink would have thrown during render and blanked the page for everyone. Guard the whole parse-and-map body so bad input degrades to null. The single-segment chargeId branch treated any one-segment path as a username, so /rewards?chargeId=x would have been rewritten to /pay-request — and this PR is what added /rewards, /history, /badges and /profile to App Links. Gate it on the same isReservedRoute + couldBeRecipient rules the web catch-all already uses. On the receipt screen, isError also trips on a failed background poll, so a flaky refetch bounced the user to /home mid-settlement. Bail out only when there's no data at all. --- src/app/(mobile-ui)/receipt/page.tsx | 11 +++++++--- src/utils/__tests__/native-routes.test.ts | 25 +++++++++++++++++++++++ src/utils/native-routes.ts | 19 ++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/receipt/page.tsx b/src/app/(mobile-ui)/receipt/page.tsx index 6227a53e86..4c64cccea0 100644 --- a/src/app/(mobile-ui)/receipt/page.tsx +++ b/src/app/(mobile-ui)/receipt/page.tsx @@ -39,11 +39,16 @@ export default function NativeReceiptPage() { refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), }) + // `isError` also trips on a failed background poll, so gate the bail-out on + // having no data at all — a flaky refetch must not yank the user off a + // receipt they're watching settle. + const isUnrecoverable = !entryId || !kind || (isError && !entry) + useEffect(() => { - if (!entryId || !kind || isError) router.replace('/home') - }, [entryId, kind, isError, router]) + if (isUnrecoverable) router.replace('/home') + }, [isUnrecoverable, router]) - if (!entryId || !kind || isError) return null + if (isUnrecoverable) return null return ( diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 35c78d7baa..fca3e571e3 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -331,6 +331,31 @@ describe('native-routes', () => { it('returns null for an unparseable link', () => { expect(deepLinkToNativePath('http://')).toBeNull() }) + + // This runs during render in the notifications list, so a throw here + // would blank the whole page rather than drop one bad row. + it.each([ + ['receipt id', '/receipt/%E0%A4%A'], + ['send recipient', '/send/%E0%A4%A'], + ['qr code', '/qr/%'], + ['bare username', '/%'], + ])('never throws on malformed percent-encoding in the %s', (_label, link) => { + expect(() => deepLinkToNativePath(link)).not.toThrow() + }) + + it.each(['/receipt/%E0%A4%A', '/send/%E0%A4%A', '/qr/%'])( + 'degrades %s to null when the decode fails mid-mapping', + (link) => { + expect(deepLinkToNativePath(link)).toBeNull() + } + ) + + it.each(['/rewards', '/history', '/badges', '/profile'])( + 'leaves the reserved route %s alone even with a chargeId param', + (route) => { + expect(deepLinkToNativePath(`${route}?chargeId=charge-123`)).toBe(`${route}?chargeId=charge-123`) + } + ) }) describe('web mode', () => { diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 00cb79f554..8a40b79c9e 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -2,6 +2,7 @@ // in capacitor (static export), dynamic routes don't work — use query params instead. // on web, use the normal path-based urls. +import { couldBeRecipient, isReservedRoute } from '@/constants/routes' import { isCapacitor } from './capacitor' // Deep links are peanut.me links by definition — that's the host the Android @@ -83,14 +84,20 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri * in-app path. */ export function deepLinkToNativePath(url: string): string | null { - let parsed: URL try { - // Relative base: push payloads carry the canonical path (`/receipt/`), - // App Links carry the full URL. Both must resolve. - parsed = new URL(url, 'https://peanut.me') + return mapDeepLink(url) } catch { + // The whole body is guarded, not just the URL parse: decodeURIComponent + // throws URIError on a stray `%`, and this runs during render in the + // notifications list — one malformed ctaDeeplink would blank the page. return null } +} + +function mapDeepLink(url: string): string | null { + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + const parsed = new URL(url, 'https://peanut.me') if (!APP_HOSTS.test(parsed.hostname)) return null const path = parsed.pathname @@ -119,7 +126,9 @@ export function deepLinkToNativePath(url: string): string | null { } // `/?chargeId=` — the catch-all profile route is disabled in // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). - if (isCapacitor() && segments.length === 1) { + // Gated by the same reserved-route/recipient rules the web catch-all uses, so + // `/rewards?chargeId=x` stays on /rewards instead of landing on /pay-request. + if (isCapacitor() && segments.length === 1 && !isReservedRoute(path) && couldBeRecipient(segments[0])) { const chargeId = parsed.searchParams.get('chargeId') if (chargeId) return chargePayUrl(chargeId) } From 86e0766fbe1650a87be24f94a81f9584200646c5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 15:15:41 +0100 Subject: [PATCH 3/3] fix(native): buffer cold-start push clicks, open external links in browser Two gaps in the push tap routing: - Capacitor retains a cold-start click only until the first JS listener attaches, which adapter.init() (useNotifications) does on its own async path. If it wins the race against useNativePlugins registering the routing callback, the retained tap was consumed by an empty listener set. The adapter now holds the last unconsumed click and replays it to the next listener. - With launch URLs suppressed, an off-domain https deep link (operator sends) opened the app and went nowhere. Hand those to the system browser instead. --- src/hooks/useNativePlugins.ts | 12 ++- src/services/onesignal/native.adapter.test.ts | 80 +++++++++++++++++++ src/services/onesignal/native.adapter.ts | 18 +++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/services/onesignal/native.adapter.test.ts diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 075baece92..d794a37545 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -61,7 +61,17 @@ export function useNativePlugins() { cleanups.push( adapter.onNotificationClick(({ deepLink, additionalData }) => { const target = additionalData.deepLink - openDeepLink(typeof target === 'string' ? target : deepLink) + const link = typeof target === 'string' ? target : deepLink + // Off-domain https links (operator sends) can't route in-app; + // with launch URLs suppressed, hand them to the system browser + // rather than silently swallowing the tap. + if (link && !deepLinkToNativePath(link) && /^https:\/\//i.test(link)) { + import('@capacitor/browser') + .then(({ Browser }) => Browser.open({ url: link })) + .catch((e) => console.warn('failed to open external push link:', e)) + return + } + openDeepLink(link) }) ) } catch (e) { diff --git a/src/services/onesignal/native.adapter.test.ts b/src/services/onesignal/native.adapter.test.ts new file mode 100644 index 0000000000..9dba3ac4ee --- /dev/null +++ b/src/services/onesignal/native.adapter.test.ts @@ -0,0 +1,80 @@ +import type { NotificationClickInfo } from './types' + +// virtual: the plugin ships ESM-only exports jest's resolver can't load; +// the adapter only ever runs in native builds where webpack resolves it. +jest.mock( + '@onesignal/capacitor-plugin', + () => ({ + __esModule: true, + default: { + initialize: jest.fn().mockResolvedValue(undefined), + login: jest.fn(), + logout: jest.fn(), + Notifications: { + addEventListener: jest.fn(), + hasPermission: jest.fn().mockResolvedValue(true), + canRequestPermission: jest.fn().mockResolvedValue(true), + requestPermission: jest.fn(), + }, + User: { + pushSubscription: { + addEventListener: jest.fn(), + getOptedInAsync: jest.fn().mockResolvedValue(true), + }, + }, + }, + }), + { virtual: true } +) + +import OneSignal from '@onesignal/capacitor-plugin' +import { nativeOneSignalAdapter } from './native.adapter' + +function getClickCallback(): (event: unknown) => void { + const call = (OneSignal.Notifications.addEventListener as jest.Mock).mock.calls.find(([name]) => name === 'click') + expect(call).toBeDefined() + return call![1] +} + +describe('nativeOneSignalAdapter cold-start click buffering', () => { + beforeAll(async () => { + process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID = 'test-app-id' + await nativeOneSignalAdapter.init() + }) + + // The Capacitor bridge retains a cold-start click only until the first JS + // listener attaches — which init() does. If the event arrives before + // useNativePlugins has registered its routing callback, the adapter must + // hold it and replay it, or the tap is silently dropped. + it('replays a click that fired before any listener registered', () => { + const fireClick = getClickCallback() + fireClick({ + notification: { + launchURL: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + }) + + const seen: NotificationClickInfo[] = [] + const off = nativeOneSignalAdapter.onNotificationClick((info) => seen.push(info)) + expect(seen).toEqual([ + { + deepLink: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + ]) + + // one-shot: a second listener must not receive the same tap again + const seenLater: NotificationClickInfo[] = [] + const offLater = nativeOneSignalAdapter.onNotificationClick((info) => seenLater.push(info)) + expect(seenLater).toEqual([]) + + // once listeners exist, clicks are delivered live, not buffered + fireClick({ result: { url: 'https://peanut.me/rewards' }, notification: { additionalData: {} } }) + expect(seen).toHaveLength(2) + expect(seenLater).toHaveLength(1) + + off() + offLater() + }) +}) diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index de867004aa..fa6eb93140 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -14,6 +14,15 @@ let initPromise: Promise | null = null const permissionListeners = new Set<(state: NotificationPermissionState) => void>() const subscriptionListeners = new Set<(optedIn: boolean) => void>() const clickListeners = new Set<(info: NotificationClickInfo) => void>() +/** + * Cold-start tap buffer. Capacitor retains the click event only until the first + * JS listener attaches — which happens inside init() (attachUnderlyingListeners), + * driven by useNotifications. useNativePlugins registers its routing callback on + * a separate async path, so if init() wins that race the retained event would be + * consumed with an empty listener set and the tap silently dropped. Hold the + * last unconsumed click here and replay it to the next listener that registers. + */ +let pendingClick: NotificationClickInfo | null = null let underlyingListenersAttached = false function attachUnderlyingListeners() { @@ -34,6 +43,10 @@ function attachUnderlyingListeners() { deepLink: event?.result?.url ?? event?.notification?.launchURL, additionalData: (event?.notification?.additionalData ?? {}) as Record, } + if (clickListeners.size === 0) { + pendingClick = info + return + } clickListeners.forEach((cb) => cb(info)) }) } @@ -90,6 +103,11 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { onNotificationClick(listener) { clickListeners.add(listener) + if (pendingClick) { + const buffered = pendingClick + pendingClick = null + listener(buffered) + } return () => clickListeners.delete(listener) }, }