Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@
<data android:pathPrefix="/withdraw/" />
<data android:path="/receipt" />
<data android:pathPrefix="/receipt/" />
<data android:path="/history" />
<data android:pathPrefix="/history/" />
<data android:path="/rewards" />
<data android:pathPrefix="/rewards/" />
<data android:path="/badges" />
<data android:pathPrefix="/badges/" />
<data android:path="/profile" />
<data android:pathPrefix="/profile/" />
</intent-filter>

</activity>
Expand All @@ -78,6 +86,15 @@
<meta-data
android:name="asset_statements"
android:resource="@string/capacitor_passkey_asset_statements" />

<!-- Don't let OneSignal fire its own ACTION_VIEW for a notification's
launch URL. The tap opens the app and useNativePlugins routes on the
deep link in-app instead — which covers destinations outside the App
Links filter above, doesn't depend on link verification, and keeps
iOS and Android on the same code path. -->
<meta-data
android:name="com.onesignal.suppressLaunchURLs"
android:value="true" />
</application>

<!-- Phone-only: require a telephony radio so Play filters out Wi-Fi-only
Expand Down
6 changes: 6 additions & 0 deletions ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
<string>Peanut may use your location to help verify your identity and prevent fraud during account setup and support.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Stop OneSignal calling openURL for a notification's launch URL. iOS won't
re-enter this app for its own universal link, so without this a tapped push
bounces the user out to Safari. useNativePlugins routes the deep link in-app
from the click listener instead. -->
<key>OneSignal_suppress_launch_urls</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
Expand Down
7 changes: 7 additions & 0 deletions src/app/(mobile-ui)/notifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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'
Expand Down Expand Up @@ -61,7 +62,7 @@
return () => {
if (element) observer.unobserve(element)
}
}, [nextPageCursor, isLoadingMore])

Check warning on line 65 in src/app/(mobile-ui)/notifications/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'loadNextPage'. Either include it or remove the dependency array

Check warning on line 65 in src/app/(mobile-ui)/notifications/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'loadNextPage'. Either include it or remove the dependency array

const loadNextPage = async () => {
// load the next page when the sentinel enters the viewport
Expand Down Expand Up @@ -157,7 +158,13 @@
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 (
<Card
key={notif.id}
Expand Down
70 changes: 70 additions & 0 deletions src/app/(mobile-ui)/receipt/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client'

import { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import PageContainer from '@/components/0_Bruddle/PageContainer'
import NavHeader from '@/components/Global/NavHeader'
import PeanutLoading from '@/components/Global/PeanutLoading'
import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt'
import { mapTransactionDataForDrawer } from '@/components/TransactionDetails/transactionTransformer'
import { resolveReceiptKind } from '@/components/TransactionDetails/strategies/registry'
import { TRANSACTIONS } from '@/constants/query.consts'
import { apiFetch } from '@/utils/api-fetch'
import { completeHistoryEntry, isFinalState, type HistoryEntry } from '@/utils/history.utils'

// Client twin of the web /receipt/[entryId] page. That one is a server component
// and is stripped from the static export (scripts/native-build.js), so a receipt
// deep link — the most common push destination — had nothing to render natively.
// deepLinkToNativePath maps /receipt/<id>?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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const {
data: entry,
isLoading,
isError,
} = useQuery({
queryKey: [TRANSACTIONS, 'entry', entryId, kind],
enabled: Boolean(entryId && kind),
queryFn: async (): Promise<HistoryEntry> => {
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 (
<PageContainer className="flex min-h-[100dvh] flex-col items-center justify-center p-6">
<div className="md:hidden">
<NavHeader title="Receipt" />
</div>
<div className="flex flex-1 flex-col items-center justify-center">
{isLoading || !entry ? (
<PeanutLoading />
) : (
<TransactionDetailsReceipt
className="w-full"
transaction={mapTransactionDataForDrawer(entry).transactionDetails}
/>
)}
</div>
</PageContainer>
)
}
28 changes: 28 additions & 0 deletions src/hooks/useNativePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 })
Expand Down
80 changes: 80 additions & 0 deletions src/services/onesignal/native.adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
18 changes: 18 additions & 0 deletions src/services/onesignal/native.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ let initPromise: Promise<void> | 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() {
Expand All @@ -34,6 +43,10 @@ function attachUnderlyingListeners() {
deepLink: event?.result?.url ?? event?.notification?.launchURL,
additionalData: (event?.notification?.additionalData ?? {}) as Record<string, unknown>,
}
if (clickListeners.size === 0) {
pendingClick = info
return
}
clickListeners.forEach((cb) => cb(info))
})
}
Expand Down Expand Up @@ -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)
},
}
84 changes: 84 additions & 0 deletions src/utils/__tests__/native-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
withdrawCountryUrl,
withdrawBankUrl,
rewriteMethodPath,
deepLinkToNativePath,
} from '../native-routes'

describe('native-routes', () => {
Expand Down Expand Up @@ -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')
})
})
})
})
Loading
Loading