From 2724e08ce21653f950a716b1cd878fea44f5c831 Mon Sep 17 00:00:00 2001 From: Alice Di Rico Date: Mon, 25 Aug 2025 18:50:41 +0200 Subject: [PATCH 1/4] chore: add logic to redirect user on wallet mini-app --- locales/en/index.json | 16 + locales/it/index.json | 16 + .../screens/LoadingScreenContent.tsx | 24 +- .../LoadingScreenContent.test.tsx.snap | 56 +++- .../__snapshots__/IngressScreen.test.tsx.snap | 11 + ts/features/ingress/screens/IngressScreen.tsx | 37 ++- ts/features/ingress/store/reducer/index.ts | 3 +- .../components/ItwOfflineAlertWrapper.tsx | 9 + .../MessageRouterScreen.test.tsx.snap | 284 ++++++++++-------- ts/sagas/startup.ts | 14 +- 10 files changed, 328 insertions(+), 142 deletions(-) diff --git a/locales/en/index.json b/locales/en/index.json index e4a49994f9d..7f417374b81 100644 --- a/locales/en/index.json +++ b/locales/en/index.json @@ -352,6 +352,11 @@ "contextualHelp": { "title": "Startup problems?" }, + "offline_access_banner": { + "title": "Ti servono i documenti?", + "content": "Non attendere, sono sempre disponibili, anche se non hai connessione!", + "action": "Vai ai documenti" + }, "connection_lost": { "title": "It looks like you don't have an active internet connection", "description": "Check your connection and restart the app." @@ -5323,6 +5328,17 @@ "footerAction": "Ricarica l'app IO" } }, + "timeout": { + "alert": { + "content": " Hai bisogno di tornare alla versione completa di IO?", + "action": "Ricarica l’app IO" + }, + "modal": { + "title": "Il tuo accesso all’app è scaduto", + "content": "###### Cosa posso fare?\n\nQuando la tua sessione di accesso all’app scade, alcuni servizi dell’app potrebbero essere limitati per motivi di sicurezza.\n\nAccedi di nuovo con SPID o CIE per continuare ad usare tutti i servizi di IO.", + "footerAction": "Log in again" + } + }, "back_online": { "alert": { "content": "Sei di nuovo online. Per usare tutti i servizi ", diff --git a/locales/it/index.json b/locales/it/index.json index c16ba3770fb..28074630d66 100644 --- a/locales/it/index.json +++ b/locales/it/index.json @@ -352,6 +352,11 @@ "contextualHelp": { "title": "Problemi di inizializzazione?" }, + "offline_access_banner": { + "title": "Ti servono i documenti?", + "content": "Non attendere, sono sempre disponibili, anche se non hai connessione!", + "action": "Vai ai documenti" + }, "connection_lost": { "title": "Non hai una connessione a internet attiva", "description": "Controlla la tua connessione e riavvia l’app." @@ -5323,6 +5328,17 @@ "footerAction": "Ricarica l'app IO" } }, + "timeout": { + "alert": { + "content": " Hai bisogno di tornare alla versione completa di IO?", + "action": "Ricarica l’app IO" + }, + "modal": { + "title": "Il tuo accesso all’app è scaduto", + "content": "###### Cosa posso fare?\n\nQuando la tua sessione di accesso all’app scade, alcuni servizi dell’app potrebbero essere limitati per motivi di sicurezza.\n\nAccedi di nuovo con SPID o CIE per continuare ad usare tutti i servizi di IO.", + "footerAction": "Accedi di nuovo" + } + }, "back_online": { "alert": { "content": "Sei di nuovo online. Per usare tutti i servizi ", diff --git a/ts/components/screens/LoadingScreenContent.tsx b/ts/components/screens/LoadingScreenContent.tsx index a09bf91ea76..cfcf22c5a8a 100644 --- a/ts/components/screens/LoadingScreenContent.tsx +++ b/ts/components/screens/LoadingScreenContent.tsx @@ -2,6 +2,7 @@ * An ingress screen to choose the real first screen the user must navigate to. */ import { + Banner, ContentWrapper, H3, IOColors, @@ -18,6 +19,7 @@ import { AnimatedPictogram, IOAnimatedPictograms } from "../ui/AnimatedPictogram"; +import I18n from "../../i18n"; const styles = StyleSheet.create({ container: { @@ -33,6 +35,7 @@ type LoadingScreenContentProps = WithTestID<{ children?: ReactNode; headerVisible?: boolean; animatedPictogramSource?: IOAnimatedPictograms; + banner: { showBanner?: boolean; onPress: () => void }; }>; export const LoadingScreenContent = (props: LoadingScreenContentProps) => { @@ -42,7 +45,8 @@ export const LoadingScreenContent = (props: LoadingScreenContentProps) => { children, headerVisible, testID, - animatedPictogramSource + animatedPictogramSource, + banner = { showBanner: false } } = props; useEffect(() => { @@ -66,10 +70,10 @@ export const LoadingScreenContent = (props: LoadingScreenContentProps) => { edges={headerVisible ? ["bottom"] : undefined} testID={testID} > - + { > {contentTitle} + {children} - {children} + + {banner.showBanner && ( + + )} + ); }; diff --git a/ts/components/screens/__tests__/__snapshots__/LoadingScreenContent.test.tsx.snap b/ts/components/screens/__tests__/__snapshots__/LoadingScreenContent.test.tsx.snap index 1778524d6ad..444f624f47b 100644 --- a/ts/components/screens/__tests__/__snapshots__/LoadingScreenContent.test.tsx.snap +++ b/ts/components/screens/__tests__/__snapshots__/LoadingScreenContent.test.tsx.snap @@ -390,6 +390,7 @@ exports[`LoadingScreenContent should match the snapshot with title, a child, hea @@ -612,11 +615,19 @@ exports[`LoadingScreenContent should match the snapshot with title, a child, hea > Test Content Title + + My test child + - - My test child - + @@ -1021,6 +1032,7 @@ exports[`LoadingScreenContent should match the snapshot with title, a child, hea @@ -1243,11 +1257,19 @@ exports[`LoadingScreenContent should match the snapshot with title, a child, hea > Test Content Title + + My test child + - - My test child - + @@ -1652,6 +1674,7 @@ exports[`LoadingScreenContent should match the snapshot with title, no children, @@ -1876,6 +1901,14 @@ exports[`LoadingScreenContent should match the snapshot with title, no children, + @@ -2280,6 +2313,7 @@ exports[`LoadingScreenContent should match the snapshot with title, no children, @@ -2504,6 +2540,14 @@ exports[`LoadingScreenContent should match the snapshot with title, no children, + diff --git a/ts/features/ingress/__test__/__snapshots__/IngressScreen.test.tsx.snap b/ts/features/ingress/__test__/__snapshots__/IngressScreen.test.tsx.snap index c6ab9d12150..db26c047cfe 100644 --- a/ts/features/ingress/__test__/__snapshots__/IngressScreen.test.tsx.snap +++ b/ts/features/ingress/__test__/__snapshots__/IngressScreen.test.tsx.snap @@ -391,6 +391,7 @@ exports[`IngressScreen Should match the snapshot 1`] = ` @@ -557,6 +560,14 @@ exports[`IngressScreen Should match the snapshot 1`] = ` + diff --git a/ts/features/ingress/screens/IngressScreen.tsx b/ts/features/ingress/screens/IngressScreen.tsx index 8bb4c933bd0..b2af9a03781 100644 --- a/ts/features/ingress/screens/IngressScreen.tsx +++ b/ts/features/ingress/screens/IngressScreen.tsx @@ -2,7 +2,7 @@ /** * An ingress screen to choose the real first screen the user must navigate to. */ -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Millisecond } from "@pagopa/ts-commons/lib/units"; import { AccessibilityInfo, View } from "react-native"; import I18n from "../../../i18n"; @@ -43,6 +43,7 @@ export const IngressScreen = () => { const [showBlockingScreen, setShowBlockingScreen] = useState(false); const [contentTitle, setContentTitle] = useState(I18n.t("startup.title")); + const [showBanner, setShowBanner] = useState(false); useEffect(() => { // Since the screen is shown for a very short time, @@ -66,6 +67,7 @@ export const IngressScreen = () => { timeouts.push( setTimeout(() => { setContentTitle(I18n.t("startup.title2")); + setShowBanner(true); timeouts.shift(); }, TIMEOUT_CHANGE_LABEL) ); @@ -83,14 +85,9 @@ export const IngressScreen = () => { }; }, [dispatch]); - useEffect(() => { - const visualizeOfflineWallet = - isConnected === false && isOfflineAccessAvailable; - - if (visualizeOfflineWallet) { - // This dispatch could be placed inside `onSuccess`, - // but executing it here ensures the startup saga stops immediately. - dispatch(setOfflineAccessReason(OfflineAccessReasonEnum.DEVICE_OFFLINE)); + const navgateOnOfflineMiniApp = useCallback( + (offlineReason: OfflineAccessReasonEnum) => { + dispatch(setOfflineAccessReason(offlineReason)); dispatch( identificationRequest(false, false, undefined, undefined, { onSuccess: () => { @@ -101,8 +98,23 @@ export const IngressScreen = () => { } }) ); + }, + [dispatch] + ); + + useEffect(() => { + const visualizeOfflineWallet = + isConnected === false && isOfflineAccessAvailable; + + if (visualizeOfflineWallet) { + navgateOnOfflineMiniApp(OfflineAccessReasonEnum.DEVICE_OFFLINE); } - }, [dispatch, isConnected, isOfflineAccessAvailable]); + }, [ + dispatch, + isConnected, + isOfflineAccessAvailable, + navgateOnOfflineMiniApp + ]); if (isConnected === false && !isOfflineAccessAvailable) { return ; @@ -123,6 +135,11 @@ export const IngressScreen = () => { testID="ingress-screen-loader-id" contentTitle={contentTitle} animatedPictogramSource="waiting" + banner={{ + showBanner: isOfflineAccessAvailable && showBanner, + onPress: () => + navgateOnOfflineMiniApp(OfflineAccessReasonEnum.TIMEOUT) + }} /> ); diff --git a/ts/features/ingress/store/reducer/index.ts b/ts/features/ingress/store/reducer/index.ts index 3fbe0e3a56d..6d727dee8cd 100644 --- a/ts/features/ingress/store/reducer/index.ts +++ b/ts/features/ingress/store/reducer/index.ts @@ -9,7 +9,8 @@ import { Action } from "../../../../store/actions/types"; export enum OfflineAccessReasonEnum { DEVICE_OFFLINE = "device_offline", // The device is offline when the app is started SESSION_REFRESH = "session_refresh", // Error on session refresh - SESSION_EXPIRED = "session_expired" // Session has expired or user has logged out + SESSION_EXPIRED = "session_expired", // Session has expired or user has logged out + TIMEOUT = "timeout" // The app has not been able to connect to the backend within a certain time } export type IngressScreenState = { isBlockingScreen: boolean; diff --git a/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx b/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx index 326ce60619b..a648b780362 100644 --- a/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx +++ b/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx @@ -106,6 +106,15 @@ const AlertWrapper = ({ }; } + if (offlineAccessReason === OfflineAccessReasonEnum.TIMEOUT) { + return { + content: I18n.t(`features.itWallet.offline.timeout.alert.content`), + action: I18n.t(`features.itWallet.offline.timeout.alert.action`), + variant: "info", + onPress: handleAppRestart + }; + } + return { content: I18n.t( `features.itWallet.offline.${offlineAccessReason}.alert.content` diff --git a/ts/features/messages/screens/__tests__/__snapshots__/MessageRouterScreen.test.tsx.snap b/ts/features/messages/screens/__tests__/__snapshots__/MessageRouterScreen.test.tsx.snap index 87cc0020e0f..edd8f349eb9 100644 --- a/ts/features/messages/screens/__tests__/__snapshots__/MessageRouterScreen.test.tsx.snap +++ b/ts/features/messages/screens/__tests__/__snapshots__/MessageRouterScreen.test.tsx.snap @@ -231,6 +231,7 @@ exports[`MessageRouterScreen should match snapshot before starting to retrieve m @@ -453,43 +456,51 @@ exports[`MessageRouterScreen should match snapshot before starting to retrieve m > Loading message details + + + + Wait a few moments + + - - - Wait a few moments - - + /> @@ -1307,43 +1321,51 @@ exports[`MessageRouterScreen should match snapshot if message data retrieval was > Loading message details + + + + Wait a few moments + + - - - Wait a few moments - - + /> @@ -3256,43 +3281,51 @@ exports[`MessageRouterScreen should match snapshot on message data retrieval suc > Loading message details + + + + Wait a few moments + + - - - Wait a few moments - - + /> @@ -4110,43 +4146,51 @@ exports[`MessageRouterScreen should match snapshot while retrieving message data > Loading message details + + + + Wait a few moments + + - - - Wait a few moments - - + /> Date: Tue, 26 Aug 2025 09:18:44 +0200 Subject: [PATCH 2/4] test: fix tests --- .../__tests__/initializeApplicationSaga.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ts/sagas/__tests__/initializeApplicationSaga.test.ts b/ts/sagas/__tests__/initializeApplicationSaga.test.ts index cd7720ec9d9..31bed1fb0d2 100644 --- a/ts/sagas/__tests__/initializeApplicationSaga.test.ts +++ b/ts/sagas/__tests__/initializeApplicationSaga.test.ts @@ -41,7 +41,10 @@ import { watchLogoutSaga } from "../../features/authentication/common/saga/watch import { cancellAllLocalNotifications } from "../../features/pushNotifications/utils"; import { handleApplicationStartupTransientError } from "../../features/startup/sagas"; import { startupTransientErrorInitialState } from "../../store/reducers/startup"; -import { isBlockingScreenSelector } from "../../features/ingress/store/selectors"; +import { + isBlockingScreenSelector, + offlineAccessReasonSelector +} from "../../features/ingress/store/selectors"; import { notificationPermissionsListener } from "../../features/pushNotifications/sagas/notificationPermissionsListener"; import { trackKeychainFailures } from "../../utils/analytics"; import { checkSession } from "../../features/authentication/common/saga/watchCheckSessionSaga"; @@ -120,6 +123,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) @@ -179,6 +184,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) @@ -232,6 +239,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) @@ -290,6 +299,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) @@ -361,6 +372,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) @@ -419,6 +432,8 @@ describe("initializeApplicationSaga", () => { .next() .call(isDeviceOfflineWithWalletSaga) .next() + .select(offlineAccessReasonSelector) + .next() .fork(watchSessionRefreshInOfflineSaga) .next() .select(remoteConfigSelector) From 7d7f0c359e209c1a2375088c216f67f920ef3bde Mon Sep 17 00:00:00 2001 From: Alice Di Rico Date: Tue, 26 Aug 2025 10:08:06 +0200 Subject: [PATCH 3/4] fix: add correct type --- ts/components/screens/LoadingScreenContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/components/screens/LoadingScreenContent.tsx b/ts/components/screens/LoadingScreenContent.tsx index cfcf22c5a8a..2a1c6faaf78 100644 --- a/ts/components/screens/LoadingScreenContent.tsx +++ b/ts/components/screens/LoadingScreenContent.tsx @@ -35,7 +35,7 @@ type LoadingScreenContentProps = WithTestID<{ children?: ReactNode; headerVisible?: boolean; animatedPictogramSource?: IOAnimatedPictograms; - banner: { showBanner?: boolean; onPress: () => void }; + banner?: { showBanner?: boolean; onPress: () => void }; }>; export const LoadingScreenContent = (props: LoadingScreenContentProps) => { From 4253e7f4462050e3c250d0879a6a9c7cc822d817 Mon Sep 17 00:00:00 2001 From: Alice Di Rico Date: Thu, 28 Aug 2025 10:44:14 +0200 Subject: [PATCH 4/4] fix: type error --- locales/en/index.json | 5 ----- locales/it/index.json | 5 ----- .../wallet/components/ItwOfflineAlertWrapper.tsx | 13 ++++++++++--- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/locales/en/index.json b/locales/en/index.json index 0f894879166..7aa0135caa5 100644 --- a/locales/en/index.json +++ b/locales/en/index.json @@ -5346,11 +5346,6 @@ "alert": { "content": " Hai bisogno di tornare alla versione completa di IO?", "action": "Ricarica l’app IO" - }, - "modal": { - "title": "Il tuo accesso all’app è scaduto", - "content": "###### Cosa posso fare?\n\nQuando la tua sessione di accesso all’app scade, alcuni servizi dell’app potrebbero essere limitati per motivi di sicurezza.\n\nAccedi di nuovo con SPID o CIE per continuare ad usare tutti i servizi di IO.", - "footerAction": "Log in again" } }, "back_online": { diff --git a/locales/it/index.json b/locales/it/index.json index 3f6e36a2d82..6d55380d723 100644 --- a/locales/it/index.json +++ b/locales/it/index.json @@ -5346,11 +5346,6 @@ "alert": { "content": " Hai bisogno di tornare alla versione completa di IO?", "action": "Ricarica l’app IO" - }, - "modal": { - "title": "Il tuo accesso all’app è scaduto", - "content": "###### Cosa posso fare?\n\nQuando la tua sessione di accesso all’app scade, alcuni servizi dell’app potrebbero essere limitati per motivi di sicurezza.\n\nAccedi di nuovo con SPID o CIE per continuare ad usare tutti i servizi di IO.", - "footerAction": "Accedi di nuovo" } }, "back_online": { diff --git a/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx b/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx index a648b780362..285ab7f4fe0 100644 --- a/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx +++ b/ts/features/itwallet/wallet/components/ItwOfflineAlertWrapper.tsx @@ -27,6 +27,7 @@ import { trackItwOfflineRicaricaAppIO } from "../../analytics"; import { useAppRestartAction } from "../hooks/useAppRestartAction.ts"; +import { TranslationKeys } from "../../../../../locales/locales.ts"; /** * A wrapper component that displays an alert to notify users when the @@ -180,21 +181,27 @@ const useOfflineAlertDetailModal = ( } }, [handleAppRestart, navigateOnAuthPage, offlineAccessReason]); + // We cast to TranslationKeys because TypeScript cannot infer that the + // dynamic keys built with offlineAccessReason exist in the i18n catalog. + // This is safe in our case because the only enum value without modal.* keys + // is TIMEOUT, and for TIMEOUT the bottom sheet is never rendered. + // Therefore at runtime we never try to resolve missing translation keys. + return useIOBottomSheetModal({ title: I18n.t( - `features.itWallet.offline.${offlineAccessReason}.modal.title` + `features.itWallet.offline.${offlineAccessReason}.modal.title` as TranslationKeys ), component: (