diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png b/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png deleted file mode 100644 index 0f1ca8a41c..0000000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_onesignal_large_icon_default.png and /dev/null differ diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 48486edfea..03f6fd807e 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -33,14 +33,17 @@ interface Props { * the retry affordance — no separate dismiss needed. */ error?: string | null onToggleReveal?: () => void - onCopy?: (value: string, field: 'pan' | 'cvv') => void + onCopy?: (value: string, field: CopyableCardField) => void /** Pre-activation preview: PAN/cardholder/expiry rendered as `?`s. * Used on AddCardEntryScreen before KYC + first spend. */ locked?: boolean className?: string } +export type CopyableCardField = 'pan' | 'expiry' | 'cvv' + const formatPan = (pan: string) => pan.replace(/(.{4})/g, '$1 ').trim() +const formatExpiry = (month: number, year: number) => `${String(month).padStart(2, '0')}/${String(year).slice(-2)}` const CardFace: FC = ({ last4, @@ -60,9 +63,9 @@ const CardFace: FC = ({ // so it never covers the PAN / expiry / CVV (or the loading skeletons). // It slides back when the card is re-masked. const detailsShown = showingDetails || loading - const [copiedField, setCopiedField] = useState<'pan' | 'cvv' | null>(null) + const [copiedField, setCopiedField] = useState(null) - const handleCopy = (value: string, field: 'pan' | 'cvv') => { + const handleCopy = (value: string, field: CopyableCardField) => { setCopiedField(field) // Clear only if still showing the same field — guards against an // earlier setTimeout overwriting a fresher copy on the other field. @@ -160,13 +163,29 @@ const CardFace: FC = ({ )}
-
- {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} - {/* ph-no-capture: expiry digits out of recordings. */} -
- {String(revealed.expiryMonth).padStart(2, '0')}/ - {String(revealed.expiryYear).slice(-2)} +
+
+ {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} + {/* ph-no-capture: expiry digits out of recordings. */} +
+ {formatExpiry(revealed.expiryMonth, revealed.expiryYear)} +
+ {onCopy && ( + + )}
@@ -181,7 +200,7 @@ const CardFace: FC = ({ onClick={() => handleCopy(revealed.cvv, 'cvv')} className="relative p-1 transition-opacity duration-instant after:absolute after:-inset-3 focus-visible:outline-[3px] focus-visible:outline-action-focus active:opacity-60" > - + )}
diff --git a/src/components/Card/YourCardScreen.tsx b/src/components/Card/YourCardScreen.tsx index d2d01359e5..b3d1690381 100644 --- a/src/components/Card/YourCardScreen.tsx +++ b/src/components/Card/YourCardScreen.tsx @@ -13,7 +13,7 @@ import ProfileMenuItem from '@/components/Profile/components/ProfileMenuItem' import { Icon } from '@/components/Global/Icons/Icon' import { Notification } from '@/components/0_Bruddle/Notification' import { useToast } from '@/components/0_Bruddle/Toast' -import CardFace from '@/components/Card/CardFace' +import CardFace, { type CopyableCardField } from '@/components/Card/CardFace' import CancelCardModal from '@/components/Card/CancelCardModal' import LockCardModal from '@/components/Card/LockCardModal' import { shouldShowAutoRenewBanner, daysUntilExpiry } from '@/components/Card/cardExpiry.utils' @@ -32,6 +32,12 @@ interface Props { onPrev?: () => void } +const COPIED_MESSAGE_KEY: Record = { + pan: 'cardNumberCopied', + expiry: 'expiryCopied', + cvv: 'cvvCopied', +} + const YourCardScreen: FC = ({ overview, card, onPrev }) => { const t = useTranslations('card.yourCard') const tGlobal = useTranslations('global') @@ -65,13 +71,13 @@ const YourCardScreen: FC = ({ overview, card, onPrev }) => { const balanceDueCents = cardBalanceDueCents(overview.balance?.spendingPower) const handleCopy = useCallback( - async (value: string, field: 'pan' | 'cvv') => { + async (value: string, field: CopyableCardField) => { if (!(await copyTextToClipboard(value))) { toast.error(tGlobal('copyToClipboard.copyFailed')) return } triggerHaptic() - toast.success(field === 'pan' ? t('cardNumberCopied') : t('cvvCopied')) + toast.success(t(COPIED_MESSAGE_KEY[field])) }, [triggerHaptic, toast, t, tGlobal] ) diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx index 83516ac401..f9156489a8 100644 --- a/src/components/Card/__tests__/CardFace.test.tsx +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -7,7 +7,7 @@ * degraded the Rain lookup). */ import React from 'react' -import { render as rtlRender, screen } from '@testing-library/react' +import { fireEvent, render as rtlRender, screen } from '@testing-library/react' import { IntlWrapper } from '@/test-utils/intl' import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' @@ -21,6 +21,17 @@ const revealed: RevealedCardDetails = { cardholderName: 'Jane Doe', } +describe('CardFace copy buttons', () => { + it('copies the expiry as MM/YY with its own button', () => { + const onCopy = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Copy expiry date' })) + expect(onCopy).toHaveBeenCalledWith('12/30', 'expiry') + fireEvent.click(screen.getByRole('button', { name: 'Copy CVV' })) + expect(onCopy).toHaveBeenCalledWith('123', 'cvv') + }) +}) + describe('CardFace cardholder name', () => { it('shows the registered name when the card is revealed', () => { render() diff --git a/src/components/Profile/components/ShowNameToggle.tsx b/src/components/Profile/components/ShowNameToggle.tsx index 0666e08691..7e6904510c 100644 --- a/src/components/Profile/components/ShowNameToggle.tsx +++ b/src/components/Profile/components/ShowNameToggle.tsx @@ -2,35 +2,77 @@ import { updateUserById } from '@/app/actions/users' import { Toggle } from '@/components/0_Bruddle/Toggle' +import { useToast } from '@/components/0_Bruddle/Toast' +import ActionModal from '@/components/Global/ActionModal' import { useAuth } from '@/context/authContext' import { useTranslations } from 'next-intl' import { useState } from 'react' -const ShowNameToggle = () => { +interface ShowNameToggleProps { + checked: boolean + /** Called with the optimistic value, so the screen reflects the setting at once. */ + onChange: (value: boolean) => void +} + +const ShowNameToggle = ({ checked, onChange }: ShowNameToggleProps) => { const t = useTranslations('profile') + const tCommon = useTranslations('common') const { fetchUser, user } = useAuth() - const [showFullName, setShowFullName] = useState(user?.user.showFullName ?? false) + const toast = useToast() + const [isConfirming, setIsConfirming] = useState(false) - const handleToggleChange = async () => { - const newValue = !showFullName - setShowFullName(newValue) + const save = async (newValue: boolean) => { + onChange(newValue) - // Fire-and-forget: don't await fetchUser() to allow quick navigation - updateUserById({ + // updateUserById RESOLVES { error } for a non-2xx or a network failure — + // it never rejects, so a catch block would let a failed save stand and + // this screen would claim the legal name is hidden while it is public. + const { error } = await updateUserById({ userId: user?.user.userId, showFullName: newValue, }) - .then(() => { - // Refetch user data in background without blocking - fetchUser() - }) - .catch((error) => { - console.error('Failed to update preferences:', error) - // Revert on error - setShowFullName(!newValue) - }) + if (error) { + onChange(!newValue) + toast.error(tCommon('genericError')) + return + } + // Refetch user data in background without blocking + fetchUser() } - return + + // Turning it on publishes the legal name next to the username, so it asks + // first. Turning it off takes nothing away and needs no confirmation. + const handleToggleChange = () => (checked ? void save(false) : setIsConfirming(true)) + + return ( + <> + + setIsConfirming(false)} + tone="warning" + icon="eye" + title={t('showFullNameConfirm.title')} + description={t('showFullNameConfirm.description')} + ctas={[ + { + text: tCommon('confirm'), + variant: 'purple', + shadowSize: '4', + onClick: () => { + setIsConfirming(false) + void save(true) + }, + }, + { + text: tCommon('cancel'), + variant: 'stroke', + onClick: () => setIsConfirming(false), + }, + ]} + /> + + ) } export default ShowNameToggle diff --git a/src/components/Profile/components/__tests__/ShowNameToggle.test.tsx b/src/components/Profile/components/__tests__/ShowNameToggle.test.tsx new file mode 100644 index 0000000000..937e32e7a8 --- /dev/null +++ b/src/components/Profile/components/__tests__/ShowNameToggle.test.tsx @@ -0,0 +1,94 @@ +/** + * ShowNameToggle — the confirmation gate. Turning the setting ON publishes the + * user's legal name next to their username, so it must ask first; turning it + * OFF saves straight away. + */ +import React from 'react' +import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' +import { IntlWrapper } from '@/test-utils/intl' +import ShowNameToggle from '@/components/Profile/components/ShowNameToggle' + +const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper }) + +const mockUpdateUserById = jest.fn() +const mockFetchUser = jest.fn() + +jest.mock('@/app/actions/users', () => ({ updateUserById: (...a: unknown[]) => mockUpdateUserById(...a) })) +const mockToastError = jest.fn() +jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: mockToastError }) })) +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ fetchUser: mockFetchUser, user: { user: { userId: 'u1' } } }), +})) +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: ({ visible, title, ctas }: any) => + visible ? ( +
+

{title}

+ {ctas?.map((c: any, i: number) => ( + + ))} +
+ ) : null, +})) + +beforeEach(() => { + jest.clearAllMocks() + mockUpdateUserById.mockResolvedValue({ data: {} }) +}) + +describe('ShowNameToggle', () => { + it('asks before turning the setting on, and saves once confirmed', async () => { + const onChange = jest.fn() + render() + + fireEvent.click(screen.getByRole('switch')) + expect(screen.getByText('Show your full name?')).toBeInTheDocument() + expect(mockUpdateUserById).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('Confirm')) + expect(onChange).toHaveBeenCalledWith(true) + await waitFor(() => expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', showFullName: true })) + }) + + it('cancelling leaves the setting off', () => { + const onChange = jest.fn() + render() + + fireEvent.click(screen.getByRole('switch')) + fireEvent.click(screen.getByText('Cancel')) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(mockUpdateUserById).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() + }) + + it('turning it off saves without a confirmation', async () => { + const onChange = jest.fn() + render() + + fireEvent.click(screen.getByRole('switch')) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(onChange).toHaveBeenCalledWith(false) + await waitFor(() => expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', showFullName: false })) + }) + + it('reverts the optimistic value when the save resolves an error', async () => { + // updateUserById resolves { error } for a non-2xx or a network failure; + // it never rejects. Treating that as success left this screen claiming + // the legal name was hidden while the server still published it. + mockUpdateUserById.mockResolvedValueOnce({ error: 'nope' }) + const onChange = jest.fn() + render() + + fireEvent.click(screen.getByRole('switch')) + + await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(true)) + expect(mockToastError).toHaveBeenCalled() + expect(mockFetchUser).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Profile/views/ProfileEdit.view.tsx b/src/components/Profile/views/ProfileEdit.view.tsx index 6a5bd52856..6760af9828 100644 --- a/src/components/Profile/views/ProfileEdit.view.tsx +++ b/src/components/Profile/views/ProfileEdit.view.tsx @@ -40,6 +40,13 @@ export const ProfileEditView = () => { // validation renders as the name field's own error instead. const [errorMessage, setErrorMessage] = useState('') const [nameError, setNameError] = useState('') + // Mirrors `showFullName` so the header above updates the moment the toggle + // flips, instead of waiting for the background user refetch to land. + const [showFullName, setShowFullName] = useState(user?.user.showFullName ?? false) + + useEffect(() => { + setShowFullName(user?.user.showFullName ?? false) + }, [user?.user.showFullName]) // split the full name into name and surname const splitName = useCallback((fullName: string) => { @@ -172,14 +179,16 @@ export const ProfileEditView = () => { } }, [formData, user, fetchUser, router, isEmailSet, canEditName, t, tCommon]) - const fullName = user?.user.fullName || user?.user?.username || '' const username = user?.user.username || '' + // The header shows what the rest of the world sees: the full name only + // while it is public, the username otherwise. + const displayName = showFullName && user?.user.fullName ? user.user.fullName : username return (
- + {/* two groups — who you are, then how we reach you. gap-6 (XL, the section step) against gap-4 (L) inside a group, so the @@ -252,7 +261,7 @@ export const ProfileEditView = () => { position="single" leading={} title={tMenu('showMyFullName')} - trailing={} + trailing={} /> )}
diff --git a/src/components/Profile/views/__tests__/About.view.test.tsx b/src/components/Profile/views/__tests__/About.view.test.tsx index 51f95567e0..21641700f6 100644 --- a/src/components/Profile/views/__tests__/About.view.test.tsx +++ b/src/components/Profile/views/__tests__/About.view.test.tsx @@ -5,7 +5,9 @@ */ import React from 'react' import { fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react' +import { NextIntlClientProvider } from 'next-intl' import { IntlWrapper } from '@/test-utils/intl' +import { loadMessages } from '@/i18n/app/messages' import en from '@/i18n/app/messages/en.json' import { AboutView } from '../About.view' @@ -107,4 +109,36 @@ describe('AboutView', () => { jest.useRealTimers() } }) + + // TASK-22146: every policy title follows the app language. The legal hrefs + // do not, so each language opens the same English documents; only the help + // link is locale-targeted, like every other DocsLink. + it.each([ + ['en', 'Terms of Service', '/en/help/security-disclosure'], + ['es-419', 'Términos de servicio', '/es-419/help/security-disclosure'], + ['pt-BR', 'Termos de Serviço', '/pt-br/help/security-disclosure'], + ] as const)( + 'in %s the policy titles follow the catalog and the legal hrefs stay put', + async (locale, termsTitle, helpHref) => { + const messages = await loadMessages(locale) + rtlRender( + + + + ) + const links = screen.getAllByRole('link') + expect(links.map((link) => link.textContent)).toEqual(Object.values(messages.profile.about.policies)) + expect(links.map((link) => link.getAttribute('href'))).toEqual([ + '/terms', + '/privacy', + '/card-terms-us', + '/card-terms-international', + '/card-esign', + '/card-privacy', + '/card-prohibited-activities', + helpHref, + ]) + expect(screen.getByRole('link', { name: termsTitle })).toHaveAttribute('href', '/terms') + } + ) }) diff --git a/src/hooks/__tests__/useCardReveal.test.ts b/src/hooks/__tests__/useCardReveal.test.ts index 63cc22045c..1bdb0bb544 100644 --- a/src/hooks/__tests__/useCardReveal.test.ts +++ b/src/hooks/__tests__/useCardReveal.test.ts @@ -4,6 +4,19 @@ import { useCardReveal } from '@/hooks/useCardReveal' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { rainApi, RainCardRateLimitError, type RainCardDetailsResponse } from '@/services/rain' +let nativeListener: ((state: { isActive: boolean }) => void) | undefined +const removeNativeListener = jest.fn() +jest.mock('@capacitor/app', () => ({ + App: { + addListener: (_event: string, cb: (state: { isActive: boolean }) => void) => { + nativeListener = cb + return Promise.resolve({ remove: removeNativeListener }) + }, + }, +})) +let onNative = false +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => onNative })) + jest.mock('@/services/rain', () => { const actual = jest.requireActual('@/services/rain') return { @@ -26,6 +39,8 @@ const details: RainCardDetailsResponse = { describe('useCardReveal', () => { beforeEach(() => { mockedGetCardDetails.mockReset() + onNative = false + nativeListener = undefined }) it('fetches and stores card details on reveal', async () => { @@ -129,6 +144,58 @@ describe('useCardReveal', () => { expect(captureSpy.mock.calls.at(-1)?.[1]?.error_message).not.toContain('correlationId') }) + it('covers details while hidden and shows them again on resume without a refetch', async () => { + mockedGetCardDetails.mockResolvedValueOnce(details) + const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 })) + await act(async () => { + await result.current.reveal() + }) + + // Backgrounded: the task-switcher snapshot must not see the PAN. + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }) + document.dispatchEvent(new Event('visibilitychange')) + window.dispatchEvent(new Event('blur')) + }) + expect(result.current.revealed).toBeNull() + + // Back from the merchant app: same payload, no second (rate-limited) fetch. + act(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + document.dispatchEvent(new Event('visibilitychange')) + }) + expect(result.current.revealed).toEqual(details) + expect(mockedGetCardDetails).toHaveBeenCalledTimes(1) + }) + + it('does not mask on blur alone (native fires it spuriously)', async () => { + mockedGetCardDetails.mockResolvedValueOnce(details) + const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 })) + await act(async () => { + await result.current.reveal() + }) + act(() => window.dispatchEvent(new Event('blur'))) + expect(result.current.revealed).toEqual(details) + }) + + it('covers details from the native lifecycle, which Android reports instead of visibilitychange', async () => { + onNative = true + mockedGetCardDetails.mockResolvedValueOnce(details) + const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 })) + await act(async () => { + await result.current.reveal() + }) + await waitFor(() => expect(nativeListener).toBeDefined()) + + // no visibilitychange on this path — the app lifecycle is the only signal + act(() => nativeListener!({ isActive: false })) + expect(result.current.revealed).toBeNull() + + act(() => nativeListener!({ isActive: true })) + expect(result.current.revealed).toEqual(details) + expect(mockedGetCardDetails).toHaveBeenCalledTimes(1) + }) + it('auto-masks after the configured timeout', async () => { jest.useFakeTimers() mockedGetCardDetails.mockResolvedValueOnce(details) diff --git a/src/hooks/__tests__/useNotifications.loginDedupe.test.ts b/src/hooks/__tests__/useNotifications.loginDedupe.test.ts new file mode 100644 index 0000000000..0c54d8c25c --- /dev/null +++ b/src/hooks/__tests__/useNotifications.loginDedupe.test.ts @@ -0,0 +1,104 @@ +import { act, renderHook, waitFor } from '@testing-library/react' + +// Init publishes `oneSignalInitialized` before its first login() resolves, so a +// false → true opt-in that lands in that window used to start a second login() +// for the same id (lastLinkedExternalId is committed only after the first +// resolves) — the same double-record race TASK-22209 closed on the change +// listener. Pin: a sync for an id whose login is in flight joins it. + +let userId = 'user-1' +const pendingLogins = new Map void; reject: (e: Error) => void }>() +const mockAdapter = { + init: jest.fn().mockResolvedValue(undefined), + login: jest.fn( + (id: string) => + new Promise((resolve, reject) => { + pendingLogins.set(id, { resolve, reject }) + }) + ), + logout: jest.fn().mockResolvedValue(undefined), + requestPermission: jest.fn().mockResolvedValue('default'), + getPermission: jest.fn().mockResolvedValue('default'), + isOptedIn: jest.fn().mockResolvedValue(false), + onPermissionChange: jest.fn(() => () => {}), + onSubscriptionChange: jest.fn((_listener: (change: PushSubscriptionChange) => void) => () => {}), + onNotificationClick: jest.fn(() => () => {}), +} +jest.mock('@/services/onesignal', () => ({ + getOneSignalAdapter: () => Promise.resolve(mockAdapter), +})) +jest.mock('@/utils/general.utils', () => ({ + getUserPreferences: () => undefined, + updateUserPreferences: jest.fn(), +})) +jest.mock('@/utils/migration.utils', () => ({ isPwaSunsetOn: () => false })) +jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) +jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: { user: { userId } } }) })) +jest.mock('posthog-js', () => ({ capture: jest.fn() })) +jest.mock('@sentry/nextjs', () => ({ + addBreadcrumb: jest.fn(), + captureException: jest.fn(), + captureMessage: jest.fn(), +})) + +import type { PushSubscriptionChange } from '@/services/onesignal' +import { useNotifications } from '../useNotifications' + +describe('useNotifications initial login', () => { + it('joins the in-flight login, and a late-settling older login keeps the newer id guarded', async () => { + const rendered = renderHook(() => useNotifications()) + await waitFor(() => expect(rendered.result.current.oneSignalInitialized).toBe(true)) + await waitFor(() => expect(mockAdapter.login).toHaveBeenCalledWith('user-1')) + expect(mockAdapter.login).toHaveBeenCalledTimes(1) + const onSubscriptionChange = mockAdapter.onSubscriptionChange.mock.calls[0][0] + + // the opt-in lands while init's login is still pending: join it, never + // start a second login for the same id (the duplicate-record race) + await act(async () => { + onSubscriptionChange({ optedIn: true, previousOptedIn: false }) + }) + expect(mockAdapter.login).toHaveBeenCalledTimes(1) + + // switch accounts while that first login is STILL pending + userId = 'user-2' + rendered.rerender() + await waitFor(() => expect(mockAdapter.login).toHaveBeenCalledWith('user-2')) + expect(mockAdapter.login).toHaveBeenCalledTimes(2) + + // the newer login lands first, then the older one settles: neither the + // guard nor the bookkeeping may end up naming the account we left + await act(async () => { + pendingLogins.get('user-2')!.resolve() + }) + await act(async () => { + pendingLogins.get('user-1')!.resolve() + }) + await act(async () => { + onSubscriptionChange({ optedIn: false, previousOptedIn: true }) + onSubscriptionChange({ optedIn: true, previousOptedIn: false }) + }) + expect(mockAdapter.login).toHaveBeenCalledTimes(2) + }) + + it('retries once when the login it joined fails', async () => { + // a joined caller sees no error of its own: without a re-check the + // device stays unlinked and every push for it reaches nobody + userId = 'user-3' + renderHook(() => useNotifications()) + await waitFor(() => expect(mockAdapter.login).toHaveBeenCalledWith('user-3')) + const callsAfterFirst = mockAdapter.login.mock.calls.length + const onSubscriptionChange = mockAdapter.onSubscriptionChange.mock.calls[0][0] + + // the opt-in joins that pending login, which then fails + await act(async () => { + onSubscriptionChange({ optedIn: true, previousOptedIn: false }) + }) + expect(mockAdapter.login).toHaveBeenCalledTimes(callsAfterFirst) + + await act(async () => { + pendingLogins.get('user-3')!.reject(new Error('network down')) + }) + await waitFor(() => expect(mockAdapter.login).toHaveBeenCalledTimes(callsAfterFirst + 1)) + expect(mockAdapter.login).toHaveBeenLastCalledWith('user-3') + }) +}) diff --git a/src/hooks/__tests__/useNotifications.subscription.test.ts b/src/hooks/__tests__/useNotifications.subscription.test.ts new file mode 100644 index 0000000000..b411a61e0f --- /dev/null +++ b/src/hooks/__tests__/useNotifications.subscription.test.ts @@ -0,0 +1,93 @@ +import { act, renderHook, waitFor } from '@testing-library/react' + +// TASK-22209: OneSignal fires the push-subscription `change` event several +// times for one opt-in (opt-in flips, token registers, server assigns the id) +// and again on a token refresh after reload. The hook used to call login() +// and capture `notification_subscribed` on every `optedIn: true`, and the +// login re-registered the half-created subscription as a second record — +// which OneSignal greets with a second welcome notification. Pin: only the +// false → true opt-in transition acts; a real re-subscribe acts again. + +const mockAdapter = { + init: jest.fn().mockResolvedValue(undefined), + login: jest.fn().mockResolvedValue(undefined), + logout: jest.fn().mockResolvedValue(undefined), + requestPermission: jest.fn().mockResolvedValue('default'), + getPermission: jest.fn().mockResolvedValue('default'), + isOptedIn: jest.fn().mockResolvedValue(false), + onPermissionChange: jest.fn(() => () => {}), + onSubscriptionChange: jest.fn((_listener: (change: PushSubscriptionChange) => void) => () => {}), + onNotificationClick: jest.fn(() => () => {}), +} +jest.mock('@/services/onesignal', () => ({ + getOneSignalAdapter: () => Promise.resolve(mockAdapter), +})) +jest.mock('@/utils/general.utils', () => ({ + getUserPreferences: () => undefined, + updateUserPreferences: jest.fn(), +})) +jest.mock('@/utils/migration.utils', () => ({ isPwaSunsetOn: () => false })) +jest.mock('@/utils/demo', () => ({ isDemoMode: () => false })) +jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: { user: { userId: 'user-1' } } }) })) +const mockCapture = jest.fn() +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) +jest.mock('@sentry/nextjs', () => ({ + addBreadcrumb: jest.fn(), + captureException: jest.fn(), + captureMessage: jest.fn(), +})) + +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import type { PushSubscriptionChange } from '@/services/onesignal' +import { useNotifications } from '../useNotifications' + +const subscribedCaptures = () => + mockCapture.mock.calls.filter(([event]) => event === ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED) + +// the SDK's `change` events for one opt-in: the opt-in flips first (no token +// yet), then the token registers, then the server assigns the id — each with +// optedIn already true; later a reload refreshes the token the same way +const optedInFlipped: PushSubscriptionChange = { optedIn: true, previousOptedIn: false } +const tokenRegistered: PushSubscriptionChange = { optedIn: true, previousOptedIn: true } +const idAssigned: PushSubscriptionChange = { optedIn: true, previousOptedIn: true } +const tokenRefreshed: PushSubscriptionChange = { optedIn: true, previousOptedIn: true } +const optedOut: PushSubscriptionChange = { optedIn: false, previousOptedIn: true } +const optedBackIn: PushSubscriptionChange = { optedIn: true, previousOptedIn: false } + +describe('useNotifications subscription change', () => { + it('acts once on one opt-in however many change events OneSignal splits it into', async () => { + const rendered = renderHook(() => useNotifications()) + await waitFor(() => expect(rendered.result.current.oneSignalInitialized).toBe(true)) + // init already linked the device to the user + expect(mockAdapter.login).toHaveBeenCalledTimes(1) + const onSubscriptionChange = mockAdapter.onSubscriptionChange.mock.calls[0][0] + + await act(async () => { + onSubscriptionChange(optedInFlipped) + onSubscriptionChange(tokenRegistered) + onSubscriptionChange(idAssigned) + }) + + expect(rendered.result.current.isPushOptedIn).toBe(true) + expect(subscribedCaptures()).toHaveLength(1) + // no second login: the init link stands, nothing to retry + expect(mockAdapter.login).toHaveBeenCalledTimes(1) + + // an already opted-in device refreshing its token on reload is not an opt-in + await act(async () => { + onSubscriptionChange(tokenRefreshed) + }) + expect(subscribedCaptures()).toHaveLength(1) + + // a real re-subscribe is a false → true transition and counts again + await act(async () => { + onSubscriptionChange(optedOut) + }) + expect(rendered.result.current.isPushOptedIn).toBe(false) + await act(async () => { + onSubscriptionChange(optedBackIn) + }) + expect(subscribedCaptures()).toHaveLength(2) + expect(mockAdapter.login).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useCardReveal.ts b/src/hooks/useCardReveal.ts index 227ca7cf50..4c7eeae31a 100644 --- a/src/hooks/useCardReveal.ts +++ b/src/hooks/useCardReveal.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { rainApi, RainCardRateLimitError, type RainCardDetailsResponse } from '@/services/rain' +import { isCapacitor } from '@/utils/capacitor' interface UseCardRevealArgs { cardId: string @@ -25,15 +26,23 @@ const DEFAULT_AUTO_MASK_MS = 30_000 /** * Fetches a card's PAN/CVV/expiry from the backend and holds it in memory - * with a safety auto-mask: hides on timeout, tab blur, and page unload so - * secrets don't linger on screen. Never persist the revealed payload — let - * it be recomputed on the next reveal. + * with a safety auto-mask on timeout so secrets don't linger on screen. + * While the page is hidden the secrets are COVERED, not cleared: iOS and + * Android snapshot the backgrounded webview for the task switcher, so the + * PAN must not be painted then — but on native, switching to the merchant + * app to paste the number is the whole point, and clearing meant the user + * came back to a masked card and a rate-limited re-reveal. Not masked on + * blur: native fires it spuriously. Never persist the revealed payload — + * let it be recomputed on the next reveal. */ export function useCardReveal({ cardId, autoMaskMs = DEFAULT_AUTO_MASK_MS }: UseCardRevealArgs): UseCardRevealResult { const [revealed, setRevealed] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [isRateLimited, setIsRateLimited] = useState(false) + // ponytail: a JS cover races the OS snapshot; FLAG_SECURE / an iOS privacy + // overlay is the upgrade if a device check ever catches the PAN in recents. + const [obscured, setObscured] = useState(false) const timeoutRef = useRef | null>(null) const inFlightRef = useRef(false) @@ -99,29 +108,34 @@ export function useCardReveal({ cardId, autoMaskMs = DEFAULT_AUTO_MASK_MS }: Use await reveal() }, [revealed, hide, reveal]) - // Auto-mask when the user switches tabs or the window loses focus — a - // bystander who glances at an unattended screen shouldn't see secrets. useEffect(() => { - if (!revealed) return - const onHide = () => setRevealed(null) - const onVisibilityChange = () => { - if (document.visibilityState === 'hidden') setRevealed(null) - } - window.addEventListener('blur', onHide) - window.addEventListener('pagehide', onHide) - document.addEventListener('visibilitychange', onVisibilityChange) - return () => { - window.removeEventListener('blur', onHide) - window.removeEventListener('pagehide', onHide) - document.removeEventListener('visibilitychange', onVisibilityChange) + const sync = () => setObscured(document.visibilityState === 'hidden') + document.addEventListener('visibilitychange', sync) + + // Android WebViews do not reliably fire visibilitychange, so the native + // lifecycle is the authority there — the same appStateChange listener + // AppLock uses to know it was backgrounded. + let removeNative: (() => void) | undefined + let cancelled = false + if (isCapacitor()) { + import('@capacitor/app') + .then(({ App }) => App.addListener('appStateChange', ({ isActive }) => setObscured(!isActive))) + .then((handle) => { + if (cancelled) handle.remove() + else removeNative = () => handle.remove() + }) + .catch(() => { + // no bridge (web bundle, old native shell) — the DOM event covers it + }) } - }, [revealed]) - useEffect(() => { return () => { + cancelled = true + removeNative?.() + document.removeEventListener('visibilitychange', sync) if (timeoutRef.current) clearTimeout(timeoutRef.current) } }, []) - return { revealed, isLoading, error, isRateLimited, reveal, hide, toggle } + return { revealed: obscured ? null : revealed, isLoading, error, isRateLimited, reveal, hide, toggle } } diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index ce9da0b64d..c582078d1d 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -65,6 +65,9 @@ const getServerSnapshot = () => INITIAL_STATE let currentExternalId: string | null = null let lastLinkedExternalId: string | null = null +// the login in flight, so a second sync for the same id joins it instead of +// starting another login() (the double-record race behind TASK-22209) +let loginInFlight: { id: string; token: object; promise: Promise } | null = null let disableExternalIdLogin = false let hasTrackedModalShown = false let initStarted = false @@ -100,14 +103,33 @@ async function syncExternalIdLink() { const id = currentExternalId if (id && lastLinkedExternalId !== id) { if (disableExternalIdLogin) return - try { - const adapter = await getOneSignalAdapter() - await adapter.login(id) - // commit only on success so transient failures retry on the next sync - lastLinkedExternalId = id - } catch (err: unknown) { - handleLoginError(err) + if (loginInFlight?.id === id) { + // join it rather than start a second login for the same id, then + // check the outcome: a joined caller that never retries would leave + // the device unlinked whenever the login it joined failed + await loginInFlight.promise + if (currentExternalId === id && lastLinkedExternalId !== id) return syncExternalIdLink() + return } + const token = {} + const promise = (async () => { + try { + const adapter = await getOneSignalAdapter() + await adapter.login(id) + // commit only on success so transient failures retry on the next + // sync, and only while this call still owns the tracker — an + // older login settling late must not name an account we left + if (loginInFlight?.token === token) lastLinkedExternalId = id + } catch (err: unknown) { + handleLoginError(err) + } finally { + // a switch to another external id may own the tracker by now — + // clearing it here would drop that newer login's guard + if (loginInFlight?.token === token) loginInFlight = null + } + })() + loginInFlight = { id, token, promise } + return promise } else if (!id && lastLinkedExternalId !== null) { lastLinkedExternalId = null try { @@ -207,27 +229,30 @@ async function ensureInitialized() { } }) - adapter.onSubscriptionChange(async (optedIn) => { + adapter.onSubscriptionChange(({ optedIn, previousOptedIn }) => { addBreadcrumb({ category: 'onesignal', message: 'subscription change', data: { optedIn } }) - // link subscription to logged-in user if available - if (currentExternalId && !disableExternalIdLogin) { - try { - await adapter.login(currentExternalId) - } catch (err: unknown) { - handleLoginError(err) - } - } - // mirror OneSignal subscription state so consumers that gate on // `isPushOptedIn` (e.g. the home carousel CTA) react without // waiting for the next permissionChange event. setState({ isPushOptedIn: optedIn }) - // hide modal when user opts in - if (optedIn) { - posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED) - setState({ showPermissionModal: false }) - } + // OneSignal fires `change` for every field it settles on a new + // subscription — the opt-in, the push token, the server-assigned + // id — and again when a token refreshes on reload. Only the one + // false → true opt-in transition is a new subscription; every + // other event reports the same one. + const isNewOptIn = optedIn && !previousOptedIn + if (!isNewOptIn) return + + // The user is already linked from init / setExternalId, so this is + // only a retry for a login that failed there. An unconditional + // login() on every change raced OneSignal's own subscription + // create and re-registered the half-created subscription under + // the user as a second record — and OneSignal sends its welcome + // notification once per record (TASK-22209). + void syncExternalIdLink() + posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED) + setState({ showPermissionModal: false }) }) // Notification tap → PostHog. OneSignal delivers push clicks to its own diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index f8529a60cf..67b03da805 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -281,6 +281,10 @@ } }, "profile": { + "showFullNameConfirm": { + "title": "Show your full name?", + "description": "Your full name becomes public on your profile, next to your username. Anyone with your Peanut link can see it. You can turn this off at any time." + }, "language": "Language", "menu": { "inviteFriends": "Invite friends to Peanut", @@ -1483,6 +1487,7 @@ "valid": "Valid", "virtual": "Virtual", "copyCardNumber": "Copy card number", + "copyExpiry": "Copy expiry date", "copyCvv": "Copy CVV", "hideDetails": "Hide card details", "showDetails": "Show card details", @@ -1584,6 +1589,7 @@ "walletAddSuccess": "Card added to your wallet", "walletAddFailed": "Couldn't add the card to your wallet. Please try again.", "cardNumberCopied": "Card number copied", + "expiryCopied": "Expiry date copied", "cvvCopied": "CVV copied", "autoRenewTitle": "Card auto renews soon", "autoRenewBody": "Your card will automatically renew in {days, plural, one {# day} other {# days}}, expiration date will change.", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index c90a281462..ddf5eab837 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -281,6 +281,10 @@ } }, "profile": { + "showFullNameConfirm": { + "title": "¿Mostrar tu nombre completo?", + "description": "Tu nombre completo se vuelve público en tu perfil, junto a tu nombre de usuario. Cualquier persona con tu enlace de Peanut puede verlo. Puedes desactivarlo cuando quieras." + }, "language": "Idioma", "menu": { "inviteFriends": "Invita amigos a Peanut", @@ -1483,6 +1487,7 @@ "valid": "Válida", "virtual": "Virtual", "copyCardNumber": "Copiar número de tarjeta", + "copyExpiry": "Copiar fecha de vencimiento", "copyCvv": "Copiar CVV", "hideDetails": "Ocultar datos de la tarjeta", "showDetails": "Mostrar datos de la tarjeta", @@ -1584,6 +1589,7 @@ "walletAddSuccess": "Tarjeta agregada a tu billetera", "walletAddFailed": "No pudimos agregar la tarjeta a tu billetera. Intenta de nuevo.", "cardNumberCopied": "Número de tarjeta copiado", + "expiryCopied": "Fecha de vencimiento copiada", "cvvCopied": "CVV copiado", "autoRenewTitle": "Tu tarjeta se renueva pronto", "autoRenewBody": "Tu tarjeta se renovará automáticamente en {days, plural, one {# día} other {# días}}, la fecha de vencimiento cambiará.", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index d19adb2b00..d2277b86b5 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -352,6 +352,9 @@ "revealed": "El interruptor de actualizaciones beta ya está visible abajo.", "appOnly": "Las actualizaciones beta solo están disponibles en la app de Peanut." } + }, + "showFullNameConfirm": { + "description": "Tu nombre completo se vuelve público en tu perfil, junto a tu nombre de usuario. Cualquier persona con tu enlace de Peanut puede verlo. Podés desactivarlo cuando quieras." } }, "settings": { diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index cb21ebc7ad..5b47a47ae4 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -281,6 +281,10 @@ } }, "profile": { + "showFullNameConfirm": { + "title": "Mostrar seu nome completo?", + "description": "Seu nome completo fica público no seu perfil, ao lado do seu nome de usuário. Qualquer pessoa com o seu link da Peanut pode ver. Você pode desativar quando quiser." + }, "language": "Idioma", "menu": { "inviteFriends": "Convide amigos para o Peanut", @@ -1483,6 +1487,7 @@ "valid": "Válido", "virtual": "Virtual", "copyCardNumber": "Copiar número do cartão", + "copyExpiry": "Copiar data de validade", "copyCvv": "Copiar CVV", "hideDetails": "Ocultar dados do cartão", "showDetails": "Mostrar dados do cartão", @@ -1584,6 +1589,7 @@ "walletAddSuccess": "Cartão adicionado à sua carteira", "walletAddFailed": "Não foi possível adicionar o cartão à sua carteira. Tente novamente.", "cardNumberCopied": "Número do cartão copiado", + "expiryCopied": "Data de validade copiada", "cvvCopied": "CVV copiado", "autoRenewTitle": "Seu cartão renova em breve", "autoRenewBody": "Seu cartão vai renovar automaticamente em {days, plural, one {# dia} other {# dias}}, a data de validade vai mudar.", diff --git a/src/services/onesignal/index.ts b/src/services/onesignal/index.ts index 9fcc6ef6a5..4590d0b9f6 100644 --- a/src/services/onesignal/index.ts +++ b/src/services/onesignal/index.ts @@ -16,4 +16,4 @@ export function getOneSignalAdapter(): Promise { return adapterPromise } -export type { NotificationPermissionState, OneSignalAdapter } from './types' +export type { NotificationPermissionState, OneSignalAdapter, PushSubscriptionChange } from './types' diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index ea31414b44..9c9bd1a88d 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -2,7 +2,12 @@ import OneSignal, { LogLevel } from '@onesignal/capacitor-plugin' import { captureMessage } from '@sentry/nextjs' import posthog from 'posthog-js' import type { NotificationClickEvent, PushSubscriptionChangedState } from '@onesignal/capacitor-plugin' -import type { NotificationClickInfo, NotificationPermissionState, OneSignalAdapter } from './types' +import type { + NotificationClickInfo, + NotificationPermissionState, + OneSignalAdapter, + PushSubscriptionChange, +} from './types' import { isOneSignalDebug } from './debug' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' @@ -16,7 +21,7 @@ async function nativePermission(): Promise { let initPromise: Promise | null = null const permissionListeners = new Set<(state: NotificationPermissionState) => void>() -const subscriptionListeners = new Set<(optedIn: boolean) => void>() +const subscriptionListeners = new Set<(change: PushSubscriptionChange) => void>() const clickListeners = new Set<(info: NotificationClickInfo) => void>() /** * Cold-start tap buffer. Capacitor retains the click event only until the first @@ -84,9 +89,12 @@ function attachUnderlyingListeners() { }) OneSignal.User.pushSubscription.addEventListener('change', (event: PushSubscriptionChangedState) => { - const optedIn = !!event.current?.optedIn + const change: PushSubscriptionChange = { + optedIn: !!event.current?.optedIn, + previousOptedIn: !!event.previous?.optedIn, + } captureSubscriptionSnapshot('subscription-change') - subscriptionListeners.forEach((cb) => cb(optedIn)) + subscriptionListeners.forEach((cb) => cb(change)) }) OneSignal.Notifications.addEventListener('click', (event: NotificationClickEvent) => { diff --git a/src/services/onesignal/types.ts b/src/services/onesignal/types.ts index 5c0493997e..d42fe15315 100644 --- a/src/services/onesignal/types.ts +++ b/src/services/onesignal/types.ts @@ -5,6 +5,18 @@ export interface NotificationClickInfo { additionalData: Record } +/** + * One push-subscription `change` event, as both SDKs shape it. OneSignal + * fires it for every field it settles on a new subscription (opt-in, token, + * then the server-assigned id) and again on token refresh, so `optedIn` alone + * cannot tell a fresh opt-in from the same subscription reported twice — the + * SDK's previous value can: a new opt-in is the one false → true transition. + */ +export interface PushSubscriptionChange { + optedIn: boolean + previousOptedIn: boolean +} + /** * Platform-agnostic surface over OneSignal. The web implementation wraps the * `react-onesignal` web SDK (Web Push + service worker); the native one wraps @@ -20,6 +32,6 @@ export interface OneSignalAdapter { getPermission(): Promise isOptedIn(): Promise onPermissionChange(listener: (state: NotificationPermissionState) => void): () => void - onSubscriptionChange(listener: (optedIn: boolean) => void): () => void + onSubscriptionChange(listener: (change: PushSubscriptionChange) => void): () => void onNotificationClick(listener: (info: NotificationClickInfo) => void): () => void } diff --git a/src/services/onesignal/web.adapter.ts b/src/services/onesignal/web.adapter.ts index 5391763a4a..90880f459e 100644 --- a/src/services/onesignal/web.adapter.ts +++ b/src/services/onesignal/web.adapter.ts @@ -1,5 +1,10 @@ import OneSignal from 'react-onesignal' -import type { NotificationClickInfo, NotificationPermissionState, OneSignalAdapter } from './types' +import type { + NotificationClickInfo, + NotificationPermissionState, + OneSignalAdapter, + PushSubscriptionChange, +} from './types' import { isOneSignalDebug } from './debug' function browserPermission(): NotificationPermissionState { @@ -10,7 +15,7 @@ function browserPermission(): NotificationPermissionState { let initPromise: Promise | null = null const permissionListeners = new Set<(state: NotificationPermissionState) => void>() -const subscriptionListeners = new Set<(optedIn: boolean) => void>() +const subscriptionListeners = new Set<(change: PushSubscriptionChange) => void>() const clickListeners = new Set<(info: NotificationClickInfo) => void>() let underlyingListenersAttached = false @@ -23,10 +28,16 @@ function attachUnderlyingListeners() { permissionListeners.forEach((cb) => cb(state)) }) - type PushSubscriptionChangeEvent = { current?: { optedIn?: boolean } | null } + type PushSubscriptionChangeEvent = { + previous?: { optedIn?: boolean } | null + current?: { optedIn?: boolean } | null + } OneSignal.User.PushSubscription.addEventListener('change', (event: PushSubscriptionChangeEvent) => { - const optedIn = !!event.current?.optedIn - subscriptionListeners.forEach((cb) => cb(optedIn)) + const change: PushSubscriptionChange = { + optedIn: !!event.current?.optedIn, + previousOptedIn: !!event.previous?.optedIn, + } + subscriptionListeners.forEach((cb) => cb(change)) }) OneSignal.Notifications.addEventListener('click', (event) => {