diff --git a/package.json b/package.json
index bb0c86960a..9e56fd5d3b 100644
--- a/package.json
+++ b/package.json
@@ -124,6 +124,7 @@
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
+ "@premieroctet/react-native-wallet": "^1.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/native": "^7.1.33",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1ff05dc50..cd8a62339f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -330,6 +330,9 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
+ '@premieroctet/react-native-wallet':
+ specifier: ^1.0.2
+ version: 1.0.2(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@@ -2369,6 +2372,13 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@premieroctet/react-native-wallet@1.0.2':
+ resolution: {integrity: sha512-It8QIdeIJSTrkeN5DzqoGLjB8pB25yHpLwlDsIJVtd6D3RsCt0GPzFva8y562aEWM3jj/+GehB4I4XRxdA/0Ig==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -11471,6 +11481,12 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@premieroctet/react-native-wallet@1.0.2(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index a9d7f7bb17..66b885a708 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -1331,4 +1331,8 @@ export type Events = {
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
+ // user pressed the Add to Wallet button
+ 'invite:action:wallet': {
+ platform: Platform['OS']
+ }
}
diff --git a/src/features/inviteFriends/components/AddToWalletButton.test.tsx b/src/features/inviteFriends/components/AddToWalletButton.test.tsx
new file mode 100644
index 0000000000..89830bb1b9
--- /dev/null
+++ b/src/features/inviteFriends/components/AddToWalletButton.test.tsx
@@ -0,0 +1,225 @@
+import {type ComponentType, type ReactNode} from 'react'
+import {Platform} from 'react-native'
+import type * as RNWalletModule from '@premieroctet/react-native-wallet'
+import {act, render, waitFor} from '@testing-library/react-native'
+
+import {AddToWalletButton} from './AddToWalletButton'
+
+jest.mock('@premieroctet/react-native-wallet', () => ({
+ __esModule: true,
+ canAddPasses: jest.fn(),
+ addPass: jest.fn(),
+ addPassWithSignedJwt: jest.fn(),
+ RNWalletView: 'RNWalletView',
+ ButtonStyle: {BLACK: 0, BLACK_OUTLINE: 1},
+ ButtonType: {PRIMARY: 0, CONDENSED: 1},
+}))
+
+// Use a getter so IS_WEB can be toggled per-test without module isolation.
+// Babel compiles named imports to property accesses on the module object,
+// so each read of IS_WEB invokes the getter dynamically.
+// The variable is prefixed with "mock" so Jest's factory scope-check allows it.
+let mockIsWeb = false
+jest.mock('#/env', () => ({
+ get IS_WEB() {
+ return mockIsWeb
+ },
+ IS_IOS: true,
+ IS_ANDROID: false,
+ IS_NATIVE: true,
+}))
+
+jest.mock('#/state/session', () => ({
+ useAgent: () => ({session: {accessJwt: 'fake'}}),
+}))
+
+jest.mock('#/analytics', () => ({
+ useAnalytics: () => ({metric: jest.fn()}),
+}))
+
+jest.mock('#/logger', () => ({
+ logger: {error: jest.fn()},
+}))
+
+jest.mock('#/components/Toast', () => ({
+ show: jest.fn(),
+}))
+
+// The Lingui macro compiles useLingui from '@lingui/react/macro' to '@lingui/react'
+// at build time, so we mock the runtime module.
+jest.mock('@lingui/react', () => {
+ const mockT = (descriptor: unknown): string => {
+ if (typeof descriptor === 'object' && descriptor !== null) {
+ const d = descriptor as {message?: string; id?: string}
+ return d.message ?? d.id ?? ''
+ }
+ return typeof descriptor === 'string' ? descriptor : ''
+ }
+ return {
+ useLingui: () => ({_: mockT, i18n: {t: mockT, _: mockT}}),
+ I18nProvider: ({children}: {children: ReactNode}) => children,
+ Trans: ({message}: {message?: string}) => message ?? '',
+ LinguiContext: {Provider: ({children}: {children: ReactNode}) => children},
+ }
+})
+
+const RNWallet = require('@premieroctet/react-native-wallet') as jest.Mocked<
+ typeof RNWalletModule
+>
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockIsWeb = false
+ ;(Platform as {OS: string}).OS = 'ios'
+ RNWallet.canAddPasses.mockResolvedValue(true)
+ RNWallet.addPass.mockResolvedValue(true)
+ RNWallet.addPassWithSignedJwt.mockResolvedValue(true)
+})
+
+// Flush any lingering microtasks/state-updates so they don't leak into the
+// next test and trigger act() warnings.
+afterEach(async () => {
+ await act(async () => {})
+})
+
+// Test 1: IS_WEB gate
+test('renders null on web', async () => {
+ mockIsWeb = true
+ const {toJSON} = render(
+ ,
+ )
+ // Flush effects - with IS_WEB=true the canAddPasses effect exits early,
+ // so the flush is just a guard.
+ await act(async () => {})
+ expect(toJSON()).toBeNull()
+})
+
+// Test 2: canAddPasses gate
+test('renders null when canAddPasses() resolves false', async () => {
+ RNWallet.canAddPasses.mockResolvedValue(false)
+ const {toJSON} = render(
+ ,
+ )
+ await waitFor(() => expect(RNWallet.canAddPasses).toHaveBeenCalled())
+ // Flush promise resolution so setCanAdd(false) runs inside act
+ await act(async () => {})
+ expect(toJSON()).toBeNull()
+})
+
+// Test 3: handle sentinel gate
+test('renders null when handle is invalid', async () => {
+ const {toJSON} = render(
+ ,
+ )
+ // handle is invalid so canAddPasses is not called; flush anyway for safety
+ await act(async () => {})
+ expect(toJSON()).toBeNull()
+})
+
+// Test 4: empty handle gate
+test('renders null when handle is empty', async () => {
+ const {toJSON} = render()
+ await act(async () => {})
+ expect(toJSON()).toBeNull()
+})
+
+// Test 5: iOS press flow
+test('iOS: mints token then calls addPass with the returned URL', async () => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ url: 'https://bsky.app/invite/pass.pkpass?theme=dusk&t=tok',
+ }),
+ })
+ const {UNSAFE_getByType} = render(
+ ,
+ )
+ await waitFor(() =>
+ UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
+ )
+ const view = UNSAFE_getByType(
+ 'RNWalletView' as unknown as ComponentType,
+ ) as unknown as {
+ props: {onPress: () => Promise}
+ }
+ const {onPress} = view.props
+ await act(async () => {
+ await onPress()
+ })
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://bsky.app/invite/pass.url',
+ expect.objectContaining({method: 'POST'}),
+ )
+ expect(RNWallet.addPass).toHaveBeenCalledWith(
+ 'https://bsky.app/invite/pass.pkpass?theme=dusk&t=tok',
+ )
+})
+
+// Test 6: Android press flow
+test('Android: fetches JWT then calls addPassWithSignedJwt', async () => {
+ ;(Platform as {OS: string}).OS = 'android'
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({jwt: 'signed-jwt'}),
+ })
+ const {UNSAFE_getByType} = render(
+ ,
+ )
+ await waitFor(() =>
+ UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
+ )
+ const view = UNSAFE_getByType(
+ 'RNWalletView' as unknown as ComponentType,
+ ) as unknown as {
+ props: {onPress: () => Promise}
+ }
+ const {onPress} = view.props
+ await act(async () => {
+ await onPress()
+ })
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://bsky.app/invite/wallet/jwt?theme=day',
+ expect.objectContaining({headers: expect.any(Object)}),
+ )
+ expect(RNWallet.addPassWithSignedJwt).toHaveBeenCalledWith('signed-jwt')
+})
+
+// Test 7: error path
+test('addPass throws – error toast and logger.error are called', async () => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({url: 'https://bsky.app/invite/pass.pkpass?t=tok'}),
+ })
+ RNWallet.addPass.mockRejectedValue(new Error('boom'))
+ const Toast = require('#/components/Toast')
+ const ToastSpy = jest.spyOn(Toast, 'show')
+ const {logger} = require('#/logger')
+ const loggerSpy = jest.spyOn(logger, 'error')
+ const {UNSAFE_getByType} = render(
+ ,
+ )
+ await waitFor(() =>
+ UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
+ )
+ const view = UNSAFE_getByType(
+ 'RNWalletView' as unknown as ComponentType,
+ ) as unknown as {
+ props: {onPress: () => Promise}
+ }
+ const {onPress} = view.props
+ await act(async () => {
+ await onPress()
+ })
+ await waitFor(() =>
+ expect(ToastSpy).toHaveBeenCalledWith(
+ 'Could not add to wallet – please try again',
+ {type: 'error'},
+ ),
+ )
+ expect(loggerSpy).toHaveBeenCalledWith(
+ 'InviteWallet: addPass failed',
+ expect.objectContaining({safeMessage: expect.any(Error)}),
+ )
+})
diff --git a/src/features/inviteFriends/components/AddToWalletButton.tsx b/src/features/inviteFriends/components/AddToWalletButton.tsx
new file mode 100644
index 0000000000..88836d1888
--- /dev/null
+++ b/src/features/inviteFriends/components/AddToWalletButton.tsx
@@ -0,0 +1,102 @@
+import {useEffect, useState} from 'react'
+import {Platform, View} from 'react-native'
+import {useLingui} from '@lingui/react/macro'
+import * as RNWallet from '@premieroctet/react-native-wallet'
+
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import * as Toast from '#/components/Toast'
+import {useAnalytics} from '#/analytics'
+import {IS_WEB} from '#/env'
+import {type InviteThemeKey} from '../themes'
+
+const BSKY_WEB = 'https://bsky.app'
+
+export function AddToWalletButton({
+ themeKey,
+ handle,
+}: {
+ themeKey: InviteThemeKey
+ handle: string
+}) {
+ const {t: l} = useLingui()
+ const ax = useAnalytics()
+ const agent = useAgent()
+ const [canAdd, setCanAdd] = useState(null)
+ const [isAdding, setIsAdding] = useState(false)
+
+ useEffect(() => {
+ if (IS_WEB) return
+ // Skip the native check if the handle is already known to be unusable.
+ if (!handle || handle === 'handle.invalid') return
+ let cancelled = false
+ ;(RNWallet.canAddPasses() as Promise)
+ .then((ok: boolean) => {
+ if (!cancelled) setCanAdd(ok)
+ })
+ .catch(() => {
+ if (!cancelled) setCanAdd(false)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [handle])
+
+ if (IS_WEB) return null
+ if (!handle || handle === 'handle.invalid') return null
+ if (canAdd !== true) return null
+
+ const onPress = async () => {
+ if (isAdding) return
+ setIsAdding(true)
+ ax.metric('invite:action:wallet', {platform: Platform.OS})
+ try {
+ const accessJwt = agent.session?.accessJwt
+ if (!accessJwt) throw new Error('no session')
+ let added: boolean
+ if (Platform.OS === 'ios') {
+ const res = await fetch(`${BSKY_WEB}/invite/pass.url`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${accessJwt}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({theme: themeKey}),
+ })
+ if (!res.ok) throw new Error(`pass.url ${res.status}`)
+ const {url} = (await res.json()) as {url: string}
+ added = await RNWallet.addPass(url)
+ } else {
+ const res = await fetch(
+ `${BSKY_WEB}/invite/wallet/jwt?theme=${themeKey}`,
+ {headers: {Authorization: `Bearer ${accessJwt}`}},
+ )
+ if (!res.ok) throw new Error(`wallet/jwt ${res.status}`)
+ const {jwt} = (await res.json()) as {jwt: string}
+ added = await RNWallet.addPassWithSignedJwt(jwt)
+ }
+ if (added) {
+ Toast.show(l`Added to your wallet`)
+ }
+ } catch (err) {
+ Toast.show(l`Could not add to wallet – please try again`, {type: 'error'})
+ logger.error('InviteWallet: addPass failed', {safeMessage: err})
+ } finally {
+ setIsAdding(false)
+ }
+ }
+
+ return (
+
+ {
+ void onPress()
+ }}
+ />
+
+ )
+}