Skip to content
3 changes: 3 additions & 0 deletions app/src/components/Mnemonic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Clipboard from '@react-native-clipboard/clipboard';
import React, { useCallback, useState } from 'react';
import { Button, Text, XStack, YStack } from 'tamagui';

import { useSettingStore } from '../stores/settingStore';
import {
black,
slate50,
Expand Down Expand Up @@ -50,11 +51,13 @@ const REDACTED = new Array(24)
const Mnemonic = ({ words = REDACTED, onRevealWords }: MnemonicProps) => {
const [revealWords, setRevealWords] = useState(false);
const [copied, setCopied] = useState(false);
const { setHasViewedRecoveryPhrase } = useSettingStore();
const copyToClipboardOrReveal = useCallback(async () => {
confirmTap();
if (!revealWords) {
// TODO: container jumps when words are revealed on android
await onRevealWords?.();
setHasViewedRecoveryPhrase(true);
return setRevealWords(previous => !previous);
}
Clipboard.setString(words.join(' '));
Expand Down
40 changes: 40 additions & 0 deletions app/src/hooks/useRecoveryPrompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { useEffect } from 'react';

import { navigationRef } from '../navigation';
import { useSettingStore } from '../stores/settingStore';
import { useModal } from './useModal';

export default function useRecoveryPrompts() {
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
useSettingStore();
const { showModal, visible } = useModal({
titleText: 'Protect your account',
bodyText:
'Enable cloud backup or save your recovery phrase so you can recover your account.',
buttonText: 'Back up now',
onButtonPress: async () => {
if (navigationRef.isReady()) {
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
}
},
onModalDismiss: () => {},
} as const);

useEffect(() => {
if (!navigationRef.isReady()) {
return;
}
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
const shouldPrompt =
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
if (shouldPrompt) {
showModal();
}
}
}, [loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase, showModal]);

return { visible };
}
2 changes: 2 additions & 0 deletions app/src/providers/authProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ReactNativeBiometrics from 'react-native-biometrics';
import Keychain from 'react-native-keychain';

import { AuthEvents } from '../consts/analytics';
import { useSettingStore } from '../stores/settingStore';
import { Mnemonic } from '../types/mnemonic';
import analytics from '../utils/analytics';

Expand Down Expand Up @@ -208,6 +209,7 @@ export const AuthProvider = ({

setIsAuthenticatingPromise(null);
setIsAuthenticated(true);
useSettingStore.getState().incrementLoginCount();
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) {
Expand Down
2 changes: 2 additions & 0 deletions app/src/screens/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Caption } from '../../components/typography/Caption';
import { useAppUpdates } from '../../hooks/useAppUpdates';
import useConnectionModal from '../../hooks/useConnectionModal';
import useHapticNavigation from '../../hooks/useHapticNavigation';
import useRecoveryPrompts from '../../hooks/useRecoveryPrompts';
import SelfCard from '../../images/card-style-1.svg';
import ScanIcon from '../../images/icons/qr_scan.svg';
import WarnIcon from '../../images/icons/warning.svg';
Expand All @@ -36,6 +37,7 @@ const ScanButton = styled(Button, {

const HomeScreen: React.FC = () => {
useConnectionModal();
useRecoveryPrompts();
const [isNewVersionAvailable, showAppUpdateModal, isModalDismissed] =
useAppUpdates();

Expand Down
15 changes: 15 additions & 0 deletions app/src/stores/settingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ interface PersistedSettingsState {
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
cloudBackupEnabled: boolean;
toggleCloudBackupEnabled: () => void;
loginCount: number;
incrementLoginCount: () => void;
hasViewedRecoveryPhrase: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
isDevMode: boolean;
setDevModeOn: () => void;
setDevModeOff: () => void;
Expand Down Expand Up @@ -43,6 +47,17 @@ export const useSettingStore = create<SettingsState>()(
toggleCloudBackupEnabled: () =>
set(oldState => ({
cloudBackupEnabled: !oldState.cloudBackupEnabled,
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0,
})),

loginCount: 0,
incrementLoginCount: () =>
set(oldState => ({ loginCount: oldState.loginCount + 1 })),
hasViewedRecoveryPhrase: false,
setHasViewedRecoveryPhrase: viewed =>
set(oldState => ({
hasViewedRecoveryPhrase: viewed,
loginCount: viewed ? 0 : oldState.loginCount,
})),

isDevMode: false,
Expand Down
52 changes: 52 additions & 0 deletions app/tests/src/hooks/useRecoveryPrompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { renderHook } from '@testing-library/react-native';

import { useModal } from '../../../src/hooks/useModal';
import useRecoveryPrompts from '../../../src/hooks/useRecoveryPrompts';
import { useSettingStore } from '../../../src/stores/settingStore';

jest.mock('../../../src/hooks/useModal');
jest.mock('../../../src/navigation', () => ({
navigationRef: {
isReady: jest.fn(() => true),
navigate: jest.fn(),
},
}));

const showModal = jest.fn();
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });

describe('useRecoveryPrompts', () => {
beforeEach(() => {
showModal.mockClear();
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});

it('shows modal on first login', () => {
useSettingStore.getState().incrementLoginCount();
renderHook(() => useRecoveryPrompts());
expect(showModal).toHaveBeenCalled();
});

it('does not show modal when login count is 4', () => {
useSettingStore.setState({ loginCount: 4 });
renderHook(() => useRecoveryPrompts());
expect(showModal).not.toHaveBeenCalled();
});

it('shows modal on eighth login', () => {
useSettingStore.setState({ loginCount: 8 });
renderHook(() => useRecoveryPrompts());
expect(showModal).toHaveBeenCalled();
});

it('does not show modal if backup already enabled', () => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
renderHook(() => useRecoveryPrompts());
expect(showModal).not.toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions app/tests/src/stores/settingStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { useSettingStore } from '../../../src/stores/settingStore';

describe('settingStore', () => {
beforeEach(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});

it('increments login count', () => {
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
});

it('resets login count when recovery phrase viewed', () => {
useSettingStore.setState({ loginCount: 2 });
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});

it('resets login count when enabling cloud backup', () => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
});