diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b8fe502f7..f430868b226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - changed: ramps: Infinite buy support according to new API - changed: Optimize login performance. - changed: Update Monero LWS server name to "Edge LWS" +- fixed: Light account/backup reminder notification banner sometimes missing on login - fixed: ramps: Various Infinite UI/UX issues - fixed: Search keyboard not dismissing when submitting search - fixed: Auto-correct not disabled for search input diff --git a/src/__tests__/actions/RequestReviewActions.test.ts b/src/__tests__/actions/RequestReviewActions.test.ts index f8563ff8898..555634eb6c9 100644 --- a/src/__tests__/actions/RequestReviewActions.test.ts +++ b/src/__tests__/actions/RequestReviewActions.test.ts @@ -3,7 +3,10 @@ import { makeMemoryDisklet } from 'disklet' import type { EdgeAccount } from 'edge-core-js' import type { Action, Dispatch } from 'redux' -import { LOCAL_SETTINGS_FILENAME } from '../../actions/LocalSettingsActions' +import { + LOCAL_SETTINGS_FILENAME, + resetLocalAccountSettingsCache +} from '../../actions/LocalSettingsActions' import { DEPOSIT_AMOUNT_THRESHOLD, FIAT_PURCHASE_COUNT_THRESHOLD, @@ -102,6 +105,8 @@ jest.mock('react-native-in-app-review', () => ({ describe('RequestReviewActions', () => { beforeEach(async () => { + // Reset the module-level cache to avoid data persistence between tests + resetLocalAccountSettingsCache() // Create a fresh disklet for each test to avoid data persistence between tests mockDisklet = makeMemoryDisklet() mockAccount = { diff --git a/src/actions/LocalSettingsActions.ts b/src/actions/LocalSettingsActions.ts index e8b0986ad91..f9cce24cf31 100644 --- a/src/actions/LocalSettingsActions.ts +++ b/src/actions/LocalSettingsActions.ts @@ -25,6 +25,16 @@ watchAccountSettings(s => { }) let readSettingsFromDisk = false + +/** + * Resets the local account settings cache. Must be called on logout to prevent + * one account's settings from persisting to a subsequent account's session. + */ +export const resetLocalAccountSettingsCache = (): void => { + readSettingsFromDisk = false + localAccountSettings = asLocalAccountSettings({}) +} + export const getLocalAccountSettings = async ( account: EdgeAccount ): Promise => { @@ -214,7 +224,12 @@ const writeAccountNotifState = async ( const localSettings = await getLocalAccountSettings(account) return await writeLocalAccountSettings(account, { ...localSettings, - notifState + // Merge with existing notifState to prevent concurrent writes from + // overwriting each other's keys + notifState: { + ...localSettings.notifState, + ...notifState + } }) } @@ -261,6 +276,13 @@ export const writeTokenWarningsShown = async ( export const readLocalAccountSettings = async ( account: EdgeAccount ): Promise => { + // If we've already read from disk, return the cached settings. + // This prevents stale disk reads from overwriting newer in-memory writes + // that may not have been persisted to disk yet. + if (readSettingsFromDisk) { + return localAccountSettings + } + try { const text = await account.localDisklet.getText(LOCAL_SETTINGS_FILENAME) const json = JSON.parse(text) @@ -273,6 +295,7 @@ export const readLocalAccountSettings = async ( // Defaults can be derived from cleaners. Only write when values change. const defaults = asLocalAccountSettings({}) emitAccountSettings(defaults) + readSettingsFromDisk = true return defaults } } diff --git a/src/actions/LoginActions.tsx b/src/actions/LoginActions.tsx index 3bdc11e5300..a7baad4fac3 100644 --- a/src/actions/LoginActions.tsx +++ b/src/actions/LoginActions.tsx @@ -39,7 +39,10 @@ import { getDeviceSettings, writeIsSurveyDiscoverShown } from './DeviceSettingsActions' -import { readLocalAccountSettings } from './LocalSettingsActions' +import { + readLocalAccountSettings, + resetLocalAccountSettingsCache +} from './LocalSettingsActions' import { registerNotificationsV2, updateNotificationSettings @@ -333,6 +336,7 @@ export function logoutRequest( const { account } = state.core Keyboard.dismiss() Airship.clear() + resetLocalAccountSettingsCache() dispatch({ type: 'LOGOUT' }) if (typeof account.logout === 'function') await account.logout() diff --git a/src/components/services/NotificationService.ts b/src/components/services/NotificationService.ts index b60d0d5b1e4..c356425d52a 100644 --- a/src/components/services/NotificationService.ts +++ b/src/components/services/NotificationService.ts @@ -107,6 +107,7 @@ export const NotificationService: React.FC = (props: Props) => { const wallets = useWatch(account, 'currencyWallets') const otpKey = useWatch(account, 'otpKey') + const username = useWatch(account, 'username') const detectedTokensRedux = useSelector( state => state.core.enabledDetectedTokens @@ -124,7 +125,7 @@ export const NotificationService: React.FC = (props: Props) => { // we only need referral-based promoId targeting. const accountFunded = useIsAccountFunded() - const isLightAccountReminder = account.id != null && account.username == null + const isLightAccountReminder = account.id != null && username == null const isOtpReminder = otpKey == null && @@ -146,6 +147,8 @@ export const NotificationService: React.FC = (props: Props) => { // Update notification info with // 1. Date last received if transitioning from incomplete to complete // 2. Reset `isBannerHidden` if it's a new notification + // NOTE: lightAccountReminder is handled by a separate effect below to + // preserve banner dismissal during the current session. useAsyncEffect( async () => { // New token(s) detected @@ -165,11 +168,6 @@ export const NotificationService: React.FC = (props: Props) => { } await updateNotificationInfo(account, 'ip2FaReminder', isIp2faReminder) - await updateNotificationInfo( - account, - 'lightAccountReminder', - isLightAccountReminder - ) await updateNotificationInfo( account, 'otpReminder', @@ -201,12 +199,10 @@ export const NotificationService: React.FC = (props: Props) => { }, [ isIp2faReminder, - isLightAccountReminder, isOtpReminder, isPwReminder, wallets, detectedTokensRedux, - notifState, accountReferral, accountReferralLoaded, countryCode, @@ -215,20 +211,38 @@ export const NotificationService: React.FC = (props: Props) => { 'NotificationServices' ) - // Make sure the backup banner is always shown on login if needed. We do this - // separately in this effect so that we can hide the banner during current - // login session if they so choose. + // Handle lightAccountReminder separately with minimal dependencies so the + // banner stays dismissed during the current session. This effect only runs + // on login (mount) and when the user backs up (isLightAccountReminder + // changes). The merge logic in writeAccountNotifState prevents race + // conditions with the main effect above. useAsyncEffect( async () => { - if (!isLightAccountReminder) return - - await writeAccountNotifInfo(account, 'lightAccountReminder', { - isBannerHidden: false, - isCompleted: false - }) + if (isLightAccountReminder) { + // Light account: always show the backup reminder banner on login + await writeAccountNotifInfo(account, 'lightAccountReminder', { + isPriority: true, + isBannerHidden: false, + isCompleted: false + }) + } else { + // Non-light account: mark complete if notification exists and isn't + // already complete (handles upgrade from light account) + const { notifState: currentNotifState } = await getLocalAccountSettings( + account + ) + const existing = currentNotifState.lightAccountReminder + if (existing != null && !existing.isCompleted) { + await writeAccountNotifInfo(account, 'lightAccountReminder', { + isPriority: true, + isBannerHidden: true, + isCompleted: true + }) + } + } }, [isLightAccountReminder], - 'NotificationServices' + 'NotificationServices:lightAccountReminder' ) return null