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:
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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': {}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user