feat(invite): add AddToWalletButton component

Wraps @premieroctet/react-native-wallet to orchestrate fetch-token-then-addPass
on iOS and fetch-JWT-then-addPassWithSignedJwt on Android. Guards on IS_WEB,
handle validity, and canAddPasses(). Fires invite:action:wallet metric on press.
This commit is contained in:
vineyardbovines
2026-06-25 17:57:23 -04:00
parent 63f359e61e
commit 3561d4de35
5 changed files with 348 additions and 0 deletions
+1
View File
@@ -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",
+16
View File
@@ -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': {}
+4
View File
@@ -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']
}
}
@@ -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(
<AddToWalletButton themeKey="dusk" handle="alice.bsky.social" />,
)
// 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(
<AddToWalletButton themeKey="dusk" handle="alice.bsky.social" />,
)
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(
<AddToWalletButton themeKey="dusk" handle="handle.invalid" />,
)
// 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(<AddToWalletButton themeKey="dusk" handle="" />)
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(
<AddToWalletButton themeKey="dusk" handle="alice.bsky.social" />,
)
await waitFor(() =>
UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
)
const view = UNSAFE_getByType(
'RNWalletView' as unknown as ComponentType,
) as unknown as {
props: {onPress: () => Promise<void>}
}
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(
<AddToWalletButton themeKey="day" handle="bob.bsky.social" />,
)
await waitFor(() =>
UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
)
const view = UNSAFE_getByType(
'RNWalletView' as unknown as ComponentType,
) as unknown as {
props: {onPress: () => Promise<void>}
}
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(
<AddToWalletButton themeKey="day" handle="alice.bsky.social" />,
)
await waitFor(() =>
UNSAFE_getByType('RNWalletView' as unknown as ComponentType),
)
const view = UNSAFE_getByType(
'RNWalletView' as unknown as ComponentType,
) as unknown as {
props: {onPress: () => Promise<void>}
}
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)}),
)
})
@@ -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<boolean | null>(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<boolean>)
.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 (
<View
style={[{alignItems: 'center'}, isAdding ? {opacity: 0.6} : null]}
pointerEvents={isAdding ? 'none' : 'auto'}>
<RNWallet.RNWalletView
buttonStyle={RNWallet.ButtonStyle.BLACK}
buttonType={RNWallet.ButtonType.PRIMARY}
onPress={() => {
void onPress()
}}
/>
</View>
)
}