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), + }) + + // `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 (isUnrecoverable) router.replace('/home') + }, [isUnrecoverable, router]) + + if (isUnrecoverable) return null + + return ( + +
+ +
+
+ {isLoading || !entry ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..d794a37545 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,33 @@ 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 + 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) { + 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/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) }, } diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2f995266fc..fca3e571e3 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,87 @@ 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() + }) + + // 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', () => { + 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..8a40b79c9e 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -2,8 +2,15 @@ // 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 +// 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 +71,35 @@ 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) + 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 const extraParams = parsed.search.replace(/^\?/, '') const segments = path.split('/').filter(Boolean) @@ -96,6 +117,21 @@ 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). + // 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) + } return appendParams(path, extraParams) }