Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions app/src/hooks/useRecoveryPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import { useEffect } from 'react';

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

export default function useRecoveryPrompts() {
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
useSettingStore();
const { getAllDocuments } = usePassport();
const { showModal, visible } = useModal({
titleText: 'Protect your account',
bodyText:
Expand All @@ -25,17 +27,36 @@
} as const);

useEffect(() => {
if (!navigationRef.isReady()) {
return;
}
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
const shouldPrompt =
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
if (shouldPrompt) {
showModal();
async function maybePrompt() {
if (!navigationRef.isReady()) {
return;
}
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
try {
const docs = await getAllDocuments();
if (Object.keys(docs).length === 0) {
return;
}
const shouldPrompt =
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
if (shouldPrompt) {
showModal();
}
} catch (error) {
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt
return;
}
}
}
}, [loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase, showModal]);
void maybePrompt();

Check warning on line 52 in app/src/hooks/useRecoveryPrompts.ts

View workflow job for this annotation

GitHub Actions / lint

Expected 'undefined' and instead saw 'void'

Check warning on line 52 in app/src/hooks/useRecoveryPrompts.ts

View workflow job for this annotation

GitHub Actions / lint

Expected 'undefined' and instead saw 'void'
}, [
loginCount,
cloudBackupEnabled,
hasViewedRecoveryPhrase,
showModal,
getAllDocuments,
]);

return { visible };
}
62 changes: 45 additions & 17 deletions app/tests/src/hooks/useRecoveryPrompts.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// 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 { renderHook, waitFor } from '@testing-library/react-native';

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

jest.mock('../../../src/hooks/useModal');
jest.mock('../../../src/providers/passportDataProvider');
jest.mock('../../../src/navigation', () => ({
navigationRef: {
isReady: jest.fn(() => true),
Expand All @@ -16,62 +18,88 @@ jest.mock('../../../src/navigation', () => ({

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

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

it('shows modal on first login', () => {
it('shows modal on first login', async () => {
useSettingStore.setState({ loginCount: 1 });
renderHook(() => useRecoveryPrompts());
expect(showModal).toHaveBeenCalled();
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});

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

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

it('does not show modal if backup already enabled', () => {
it('does not show modal if backup already enabled', async () => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
renderHook(() => useRecoveryPrompts());
expect(showModal).not.toHaveBeenCalled();
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});

it('does not show modal when navigation is not ready', () => {
it('does not show modal when navigation is not ready', async () => {
const navigationRef = require('../../../src/navigation').navigationRef;
navigationRef.isReady.mockReturnValueOnce(false);
useSettingStore.setState({ loginCount: 1 });
renderHook(() => useRecoveryPrompts());
expect(showModal).not.toHaveBeenCalled();
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});

it('does not show modal when recovery phrase has been viewed', () => {
it('does not show modal when recovery phrase has been viewed', async () => {
useSettingStore.setState({ loginCount: 1, hasViewedRecoveryPhrase: true });
renderHook(() => useRecoveryPrompts());
expect(showModal).not.toHaveBeenCalled();
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});

it('shows modal for other valid login counts', () => {
[2, 3, 13, 18].forEach(count => {
it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({});
useSettingStore.setState({ loginCount: 1 });
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});

it('shows modal for other valid login counts', async () => {
for (const count of [2, 3, 13, 18]) {
showModal.mockClear();
useSettingStore.setState({ loginCount: count });
renderHook(() => useRecoveryPrompts());
expect(showModal).toHaveBeenCalled();
});
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
}
});

it('returns correct visible state', () => {
Expand Down