diff --git a/ts/features/messages/analytics/index.ts b/ts/features/messages/analytics/index.ts index 96fde7ede6e..c8939737924 100644 --- a/ts/features/messages/analytics/index.ts +++ b/ts/features/messages/analytics/index.ts @@ -109,7 +109,7 @@ export function trackThirdPartyMessageAttachmentBadFormat( export function trackThirdPartyMessageAttachmentCorruptedFile( messageId: UIMessageId, - serviceId: ServiceId | undefined + serviceId?: ServiceId ) { void mixpanelTrack( "THIRD_PARTY_MESSAGE_ATTACHMENT_CORRUPTED_FILE", diff --git a/ts/features/messages/components/MessageAttachment/DSPdfViewer.tsx b/ts/features/messages/components/MessageAttachment/DSPdfViewer.tsx new file mode 100644 index 00000000000..9ec31ae22df --- /dev/null +++ b/ts/features/messages/components/MessageAttachment/DSPdfViewer.tsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; +import { StyleSheet } from "react-native"; +import Pdf from "react-native-pdf"; +import { IOColors } from "@pagopa/io-app-design-system"; +import { DSLoadingSpinnerOverlay } from "../../designsystem/DSLoadingSpinnerOverlay"; +import I18n from "../../../../i18n"; + +const styles = StyleSheet.create({ + pdf: { + flex: 1, + backgroundColor: IOColors.bluegrey + } +}); + +type OwnProps = { + downloadPath: string; +}; + +type Props = OwnProps & Omit, "source">; + +export const DSPdfViewer = ({ + style, + downloadPath, + onError, + onLoadComplete, + ...rest +}: Props) => { + const [isLoading, setIsLoading] = useState(true); + return ( + + { + setIsLoading(false); + onLoadComplete?.(...args); + }} + onError={(...args) => { + setIsLoading(false); + onError?.(...args); + }} + /> + + ); +}; diff --git a/ts/features/messages/components/MessageDetail/index.tsx b/ts/features/messages/components/MessageDetail/index.tsx index 31a2bf26c1d..245dc7c0bad 100644 --- a/ts/features/messages/components/MessageDetail/index.tsx +++ b/ts/features/messages/components/MessageDetail/index.tsx @@ -155,17 +155,20 @@ const MessageDetailsComponent = ({ const messageTitle = useIOSelector(state => messageTitleSelector(state, messageId)) ?? title; + const serviceIdOpt = service?.id; const openAttachment = useCallback( (attachment: UIAttachment) => { navigation.navigate(MESSAGES_ROUTES.MESSAGES_NAVIGATOR, { screen: MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT, params: { messageId, - attachmentId: attachment.id + serviceId: serviceIdOpt, + attachmentId: attachment.id, + isPN: false } }); }, - [messageId, navigation] + [messageId, navigation, serviceIdOpt] ); const renderThirdPartyAttachments = useCallback( diff --git a/ts/features/messages/designsystem/DSInfoScreenComponent.tsx b/ts/features/messages/designsystem/DSInfoScreenComponent.tsx new file mode 100644 index 00000000000..8ef4d2a62b7 --- /dev/null +++ b/ts/features/messages/designsystem/DSInfoScreenComponent.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { Body, H2, VSpacer } from "@pagopa/io-app-design-system"; +import { useFocusEffect } from "@react-navigation/native"; +import { setAccessibilityFocus } from "../../../utils/accessibility"; + +type Props = { + image: React.ReactNode; + title: string; + // this is necessary in order to render text with different formatting + body?: string | React.ReactNode; +}; + +const styles = StyleSheet.create({ + main: { + padding: 24, + flex: 1, + alignItems: "center", + justifyContent: "center" + }, + textAlignCenter: { + textAlign: "center" + } +}); + +const renderNode = (body: string | React.ReactNode) => { + if (typeof body === "string") { + return ( + + {body} + + ); + } + + return body; +}; + +/** + * A base screen that displays one image, text, and one bottom button + * @param props + * @constructor + */ +export const DSInfoScreenComponent: React.FunctionComponent = props => { + const elementRef = React.createRef(); + useFocusEffect( + React.useCallback(() => setAccessibilityFocus(elementRef), [elementRef]) + ); + + return ( + + {props.image} + +

+ {props.title} +

+ + {renderNode(props.body)} +
+ ); +}; diff --git a/ts/features/messages/designsystem/DSLoadingSpinnerOverlay.tsx b/ts/features/messages/designsystem/DSLoadingSpinnerOverlay.tsx new file mode 100644 index 00000000000..d6871e5ac3b --- /dev/null +++ b/ts/features/messages/designsystem/DSLoadingSpinnerOverlay.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { StyleSheet, View } from "react-native"; +import { + Body, + ButtonOutline, + IOColors, + IOStyles, + LoadingSpinner, + hexToRgba +} from "@pagopa/io-app-design-system"; +import I18n from "../../../i18n"; + +const styles = StyleSheet.create({ + back: { + zIndex: 0 + }, + container: { + flex: 1 + }, + overlay: { + position: "absolute", + inset: 0, + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: IOColors.white, + zIndex: 1, + justifyContent: "center", + opacity: 1 + }, + refreshBox: { + height: 100, + flex: 1, + justifyContent: "center", + alignItems: "center" + }, + textCaption: { + padding: 24 + }, + whiteBg: { + backgroundColor: IOColors.white + } +}); + +type Props = Readonly<{ + backgroundColor?: string; + isLoading: boolean; + loadingCaption?: string; + opacity?: number; + onCancel?: () => void; + children: React.ReactNode; +}>; + +/** + * A Component to display and overlay spinner conditionally + */ +export const DSLoadingSpinnerOverlay = ({ + backgroundColor, + isLoading, + loadingCaption, + opacity = 1, + onCancel, + children +}: Props) => ( + + {isLoading && ( + + + + + + {loadingCaption || I18n.t("global.remoteStates.wait")} + + + {onCancel && ( + + + + )} + + + )} + {children} + +); diff --git a/ts/features/messages/navigation/MessagesNavigator.tsx b/ts/features/messages/navigation/MessagesNavigator.tsx index 75527b7e274..54cad7af9ae 100644 --- a/ts/features/messages/navigation/MessagesNavigator.tsx +++ b/ts/features/messages/navigation/MessagesNavigator.tsx @@ -10,42 +10,65 @@ import { useIOSelector } from "../../../store/hooks"; import { isGestureEnabled } from "../../../utils/navigation"; import { isPnEnabledSelector } from "../../../store/reducers/backendStatus"; import { MessageDetailAttachment } from "../screens/MessageAttachment"; +import { isDesignSystemEnabledSelector } from "../../../store/reducers/persistedPreferences"; +import { DSMessageAttachment } from "../screens/DSMessageAttachment"; import { MessagesParamsList } from "./params"; import { MESSAGES_ROUTES } from "./routes"; const Stack = createStackNavigator(); export const MessagesStackNavigator = () => { + const isDesignSystemEnabled = useIOSelector(isDesignSystemEnabledSelector); const isPnEnabled = useIOSelector(isPnEnabledSelector); return ( {isPnEnabled && ( - + )} ); diff --git a/ts/features/messages/navigation/params.ts b/ts/features/messages/navigation/params.ts index 162ed2e37a7..2377739f3d4 100644 --- a/ts/features/messages/navigation/params.ts +++ b/ts/features/messages/navigation/params.ts @@ -3,15 +3,15 @@ import EUCOVIDCERT_ROUTES from "../../euCovidCert/navigation/routes"; import PN_ROUTES from "../../pn/navigation/routes"; import { MessageRouterScreenNavigationParams } from "../screens/MessageRouterScreen"; import { MessageDetailScreenNavigationParams } from "../screens/MessageDetailScreen"; -import { MessageDetailAttachmentNavigationParams } from "../screens/MessageAttachment"; import { EUCovidCertParamsList } from "../../euCovidCert/navigation/params"; import { PnParamsList } from "../../pn/navigation/params"; +import { DSMessageAttachmentNavigationParams } from "../screens/DSMessageAttachment"; import { MESSAGES_ROUTES } from "./routes"; export type MessagesParamsList = { [MESSAGES_ROUTES.MESSAGE_ROUTER]: MessageRouterScreenNavigationParams; [MESSAGES_ROUTES.MESSAGE_DETAIL]: MessageDetailScreenNavigationParams; - [MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT]: MessageDetailAttachmentNavigationParams; + [MESSAGES_ROUTES.MESSAGE_DETAIL_ATTACHMENT]: DSMessageAttachmentNavigationParams; [EUCOVIDCERT_ROUTES.MAIN]: NavigatorScreenParams; [PN_ROUTES.MAIN]: NavigatorScreenParams; }; diff --git a/ts/features/messages/screens/DSMessageAttachment.tsx b/ts/features/messages/screens/DSMessageAttachment.tsx new file mode 100644 index 00000000000..c1af95a3c76 --- /dev/null +++ b/ts/features/messages/screens/DSMessageAttachment.tsx @@ -0,0 +1,238 @@ +import React, { useCallback, useState } from "react"; +import { pipe } from "fp-ts/lib/function"; +import * as B from "fp-ts/lib/boolean"; +import ReactNativeBlobUtil from "react-native-blob-util"; +import { FooterWithButtons, Pictogram } from "@pagopa/io-app-design-system"; +import { DSInfoScreenComponent } from "../designsystem/DSInfoScreenComponent"; +import I18n from "../../../i18n"; +import { useIOSelector } from "../../../store/hooks"; +import { downloadedMessageAttachmentSelector } from "../store/reducers/downloads"; +import { UIAttachment, UIAttachmentId, UIMessageId } from "../types"; +import { isIos } from "../../../utils/platform"; +import { share } from "../../../utils/share"; +import { IOToast } from "../../../components/Toast"; +import { useHeaderSecondLevel } from "../../../hooks/useHeaderSecondLevel"; +import { DSPdfViewer } from "../components/MessageAttachment/DSPdfViewer"; +import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; +import { MessagesParamsList } from "../navigation/params"; +import { + trackThirdPartyMessageAttachmentCorruptedFile, + trackThirdPartyMessageAttachmentPreviewSuccess, + trackThirdPartyMessageAttachmentUserAction +} from "../analytics"; +import { ServiceId } from "../../../../definitions/backend/ServiceId"; +import { + trackPNAttachmentOpen, + trackPNAttachmentOpeningSuccess, + trackPNAttachmentSave, + trackPNAttachmentSaveShare, + trackPNAttachmentShare +} from "../../pn/analytics"; + +export type DSMessageAttachmentNavigationParams = Readonly<{ + messageId: UIMessageId; + attachmentId: UIAttachmentId; + isPN: boolean; + serviceId?: ServiceId; +}>; + +const renderFooter = ( + attachment: UIAttachment, + downloadPath: string, + isPN: boolean, + attachmentCategory?: string +) => + isIos ? ( + { + onShare(isPN, attachmentCategory); + ReactNativeBlobUtil.ios.presentOptionsMenu(downloadPath); + }, + label: I18n.t("messagePDFPreview.singleBtn") + } + }} + /> + ) : ( + { + onShare(isPN, attachmentCategory); + share(`file://${downloadPath}`, undefined, false)().catch(_ => { + IOToast.show(I18n.t("messagePDFPreview.errors.sharing")); + }); + }, + label: I18n.t("global.buttons.share") + } + }} + third={{ + type: "Outline", + buttonProps: { + accessibilityLabel: I18n.t("messagePDFPreview.save"), + onPress: () => { + onDownload(isPN, attachmentCategory); + ReactNativeBlobUtil.MediaCollection.copyToMediaStore( + { + name: attachment.displayName, + parentFolder: "", + mimeType: attachment.contentType + }, + "Download", + downloadPath + ) + .then(_ => { + IOToast.show( + I18n.t("messagePDFPreview.savedAtLocation", { + name: attachment.displayName + }) + ); + }) + .catch(_ => { + IOToast.error(I18n.t("messagePDFPreview.errors.saving")); + }); + }, + label: I18n.t("messagePDFPreview.save") + } + }} + secondary={{ + type: "Solid", + buttonProps: { + accessibilityLabel: I18n.t("messagePDFPreview.open"), + onPress: () => { + onOpen(isPN, attachmentCategory); + ReactNativeBlobUtil.android + .actionViewIntent(downloadPath, attachment.contentType) + .catch(_ => { + IOToast.error(I18n.t("messagePDFPreview.errors.opening")); + }); + }, + label: I18n.t("messagePDFPreview.open") + } + }} + /> + ); + +const onPDFError = ( + messageId: UIMessageId, + isPN: boolean, + serviceId?: ServiceId, + attachmentCategory?: string +) => + pipe( + isPN, + B.fold( + () => { + trackThirdPartyMessageAttachmentCorruptedFile(messageId, serviceId); + IOToast.error(I18n.t("messageDetails.attachments.corruptedFile")); + }, + () => trackPNAttachmentOpeningSuccess("error", attachmentCategory) + ) + ); + +const onLoadComplete = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentPreviewSuccess(), + () => trackPNAttachmentOpeningSuccess("displayer", attachmentCategory) + ) + ); + +const onShare = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("share"), + () => + pipe( + isIos, + B.fold( + () => trackPNAttachmentShare(attachmentCategory), + () => trackPNAttachmentSaveShare(attachmentCategory) + ) + ) + ) + ); + +const onOpen = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("open"), + () => trackPNAttachmentOpen(attachmentCategory) + ) + ); + +const onDownload = (isPN: boolean, attachmentCategory?: string) => + pipe( + isPN, + B.fold( + () => trackThirdPartyMessageAttachmentUserAction("download"), + () => trackPNAttachmentSave(attachmentCategory) + ) + ); + +export const DSMessageAttachment = ( + props: IOStackNavigationRouteProps< + MessagesParamsList, + "MESSAGE_DETAIL_ATTACHMENT" + > +): React.ReactElement => { + const { messageId, attachmentId, isPN, serviceId } = props.route.params; + const [isPDFRenderingError, setIsPDFRenderingError] = useState(false); + + const downloadedAttachment = useIOSelector(state => + downloadedMessageAttachmentSelector(state, messageId, attachmentId) + ); + const attachmentOpt = downloadedAttachment?.attachment; + const attachmentCategory = attachmentOpt?.category; + const downloadPathOpt = downloadedAttachment?.path; + + const onPDFRenderingError = useCallback(() => { + setIsPDFRenderingError(true); + onPDFError(messageId, isPN, serviceId, attachmentCategory); + }, [attachmentCategory, messageId, isPN, serviceId]); + + useHeaderSecondLevel({ + title: I18n.t("messagePDFPreview.title"), + supportRequest: true + }); + + if (!attachmentOpt || !downloadPathOpt) { + return ( + } + title={I18n.t("global.genericError")} + body={I18n.t("messageDetails.submitBugText")} + /> + ); + } + + // Safe area view testId message-attachment-preview + return ( + <> + {isPDFRenderingError ? ( + } + title={I18n.t("messagePDFPreview.errors.previewing.title")} + body={I18n.t("messagePDFPreview.errors.previewing.body")} + /> + ) : ( + onLoadComplete(isPN, attachmentCategory)} + /> + )} + {renderFooter(attachmentOpt, downloadPathOpt, isPN, attachmentCategory)} + + ); +}; diff --git a/ts/features/messages/screens/MessageAttachment.tsx b/ts/features/messages/screens/MessageAttachment.tsx index 03d545ed4c6..b37d463145e 100644 --- a/ts/features/messages/screens/MessageAttachment.tsx +++ b/ts/features/messages/screens/MessageAttachment.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef } from "react"; import { useNavigation } from "@react-navigation/native"; import * as O from "fp-ts/lib/Option"; import I18n from "../../../i18n"; -import { UIAttachmentId, UIMessageId } from "../types"; import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; import { MessageAttachmentPreview } from "../components/MessageAttachmentPreview"; import { MessagesParamsList } from "../navigation/params"; @@ -16,11 +15,6 @@ import { trackThirdPartyMessageAttachmentUserAction } from "../analytics"; -export type MessageDetailAttachmentNavigationParams = Readonly<{ - messageId: UIMessageId; - attachmentId: UIAttachmentId; -}>; - export const MessageDetailAttachment = ( props: IOStackNavigationRouteProps< MessagesParamsList, diff --git a/ts/features/messages/store/reducers/downloads.ts b/ts/features/messages/store/reducers/downloads.ts index 5fd32447df3..3cf6630f74e 100644 --- a/ts/features/messages/store/reducers/downloads.ts +++ b/ts/features/messages/store/reducers/downloads.ts @@ -1,3 +1,5 @@ +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; import * as pot from "@pagopa/ts-commons/lib/pot"; import { createSelector } from "reselect"; import { getType } from "typesafe-actions"; @@ -107,3 +109,17 @@ export const downloadPotForMessageAttachmentSelector = createSelector( return pot.none; } ); + +export const downloadedMessageAttachmentSelector = ( + state: GlobalState, + messageId: UIMessageId, + attachmentId: UIAttachmentId +) => + pipe( + state.entities.messages.downloads[messageId], + O.fromNullable, + O.chainNullableK(messageDownloads => messageDownloads[attachmentId]), + O.map(pot.toOption), + O.flatten, + O.toUndefined + ); diff --git a/ts/features/pn/utils/__tests__/index.test.ts b/ts/features/pn/utils/__tests__/index.test.ts index 3798dad00b0..f16d3b7a63a 100644 --- a/ts/features/pn/utils/__tests__/index.test.ts +++ b/ts/features/pn/utils/__tests__/index.test.ts @@ -3,6 +3,7 @@ import { isPNOptInMessage } from ".."; import { UIService } from "../../../../store/reducers/entities/services/types"; import { GlobalState } from "../../../../store/reducers/types"; import { CTAS } from "../../../messages/types/MessageCTA"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; const pnOptInServiceId = () => "optInServiceId"; const navigateToServiceLink = () => @@ -188,7 +189,7 @@ const isPNOptInMessageTestInput: Array = [ CTAs: getMaybeCTAs(), service: { ...getMockService(), - id: "NotTheOptInOne" + id: "NotTheOptInOne" as ServiceId }, state: getMockState() }, diff --git a/ts/store/reducers/entities/services/types.ts b/ts/store/reducers/entities/services/types.ts index 2e1d6ee3cdd..e1bdac128a4 100644 --- a/ts/store/reducers/entities/services/types.ts +++ b/ts/store/reducers/entities/services/types.ts @@ -1,11 +1,12 @@ import { ImageURISource } from "react-native"; import { ServicePublic } from "../../../../../definitions/backend/ServicePublic"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; /** * Domain-specific representation of a Service with aggregated data. */ export type UIService = { - id: string; + id: ServiceId; name: string; organizationName: string; organizationFiscalCode: string;