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() + }} + /> + + ) +}