diff --git a/ts/features/messages/components/MessageDetail/MessageDetailHeader.tsx b/ts/features/messages/components/MessageDetail/MessageDetailHeader.tsx new file mode 100644 index 00000000000..849cf89df7c --- /dev/null +++ b/ts/features/messages/components/MessageDetail/MessageDetailHeader.tsx @@ -0,0 +1,48 @@ +import { Divider, H3, LabelSmall, VSpacer } from "@pagopa/io-app-design-system"; +import React, { PropsWithChildren } from "react"; +import { StyleSheet, View } from "react-native"; +import { localeDateFormat } from "../../../../utils/locale"; +import I18n from "../../../../i18n"; +import { ServicePublic } from "../../../../../definitions/backend/ServicePublic"; + +export type MessageDetailHeaderProps = PropsWithChildren<{ + createdAt: Date; + subject: string; + sender?: string; + service?: ServicePublic; +}>; + +const styles = StyleSheet.create({ + header: { + paddingHorizontal: 24 + } +}); + +const MessageHeaderContent = ({ + subject, + createdAt +}: MessageDetailHeaderProps) => ( + <> +

{subject}

+ + + {localeDateFormat( + createdAt, + I18n.t("global.dateFormats.fullFormatShortMonthLiteralWithTime") + )} + + +); + +export const MessageDetailHeader = ({ + children, + ...rest +}: MessageDetailHeaderProps) => ( + + {children} + + + + {/* TODO: Add MessageHeaderService */} + +); diff --git a/ts/features/messages/components/MessageDetail/__tests__/MessageDetailHeader.test.tsx b/ts/features/messages/components/MessageDetail/__tests__/MessageDetailHeader.test.tsx new file mode 100644 index 00000000000..5539b0d3fdc --- /dev/null +++ b/ts/features/messages/components/MessageDetail/__tests__/MessageDetailHeader.test.tsx @@ -0,0 +1,35 @@ +import React, { ComponentProps } from "react"; +import { render } from "@testing-library/react-native"; +import { MessageDetailHeader } from "../MessageDetailHeader"; +import { ServicePublic } from "../../../../../../definitions/backend/ServicePublic"; + +const service = { + service_id: "serviceId", + service_name: "health", + organization_name: "Organization foo", + department_name: "Department one", + organization_fiscal_code: "OFSAAAAAA" +} as ServicePublic; + +const defaultProps: ComponentProps = { + subject: "Subject", + createdAt: new Date("2021-10-18T16:00:30.541Z") +}; + +describe("MessageDetailHeader component", () => { + it("should match the snapshot with default props", () => { + const component = render(); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("should match the snapshot with all props", () => { + const component = render( + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/ts/features/messages/components/MessageDetail/__tests__/__snapshots__/MessageDetailHeader.test.tsx.snap b/ts/features/messages/components/MessageDetail/__tests__/__snapshots__/MessageDetailHeader.test.tsx.snap new file mode 100644 index 00000000000..d1597d22480 --- /dev/null +++ b/ts/features/messages/components/MessageDetail/__tests__/__snapshots__/MessageDetailHeader.test.tsx.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageDetailHeader component should match the snapshot with all props 1`] = ` + + + Subject + + + + 18 Oct 2021, 16:00 + + + + +`; + +exports[`MessageDetailHeader component should match the snapshot with default props 1`] = ` + + + Subject + + + + 18 Oct 2021, 16:00 + + + + +`; diff --git a/ts/features/pn/__mocks__/message.ts b/ts/features/pn/__mocks__/message.ts new file mode 100644 index 00000000000..6bfc8eeaccb --- /dev/null +++ b/ts/features/pn/__mocks__/message.ts @@ -0,0 +1,46 @@ +import { ThirdPartyMessageWithContent } from "../../../../definitions/backend/ThirdPartyMessageWithContent"; +import { message_1 } from "../../messages/__mocks__/message"; +import { ATTACHMENT_CATEGORY } from "../../messages/types/attachmentCategory"; + +export const thirdPartyMessage: ThirdPartyMessageWithContent = { + ...message_1, + created_at: new Date("2020-01-01T00:00:00.000Z"), + third_party_message: { + details: { + abstract: "######## abstract ########", + attachments: [ + { + messageId: message_1.id, + id: "1", + displayName: "A First Attachment", + contentType: "application/pdf", + category: ATTACHMENT_CATEGORY.DOCUMENT, + resourceUrl: { href: "/resource/attachment1.pdf" } + }, + { + messageId: message_1.id, + id: "2", + displayName: "A Second Attachment", + contentType: "application/pdf", + category: ATTACHMENT_CATEGORY.DOCUMENT, + resourceUrl: { href: "/resource/attachment2.pdf" } + } + ], + iun: "731143-7-0317-8200-0", + subject: "######## subject ########", + recipients: [ + { + recipientType: "-", + taxId: "AAABBB00A00A000A", + denomination: "AaAaAa BbBbBb", + payment: { + noticeCode: "026773337463073118", + creditorTaxId: "00000000009" + } + } + ], + notificationStatusHistory: [], + senderDenomination: "Sender denomination" + } + } +}; diff --git a/ts/features/pn/components/LegacyMessageDetails.tsx b/ts/features/pn/components/LegacyMessageDetails.tsx new file mode 100644 index 00000000000..78d59087e5e --- /dev/null +++ b/ts/features/pn/components/LegacyMessageDetails.tsx @@ -0,0 +1,193 @@ +import React, { useCallback, createRef, useRef } from "react"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import * as RA from "fp-ts/lib/ReadonlyArray"; +import * as SEP from "fp-ts/lib/Separated"; +import { View } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import { + IOVisualCostants, + ListItemInfoCopy, + VSpacer +} from "@pagopa/io-app-design-system"; +import { ServicePublic } from "../../../../definitions/backend/ServicePublic"; +import { H5 } from "../../../components/core/typography/H5"; +import I18n from "../../../i18n"; +import { useIOSelector } from "../../../store/hooks"; +import { pnFrontendUrlSelector } from "../../../store/reducers/backendStatus"; +import { UIAttachment, UIMessageId } from "../../messages/types"; +import { clipboardSetStringWithFeedback } from "../../../utils/clipboard"; +import { LegacyMessageAttachments } from "../../messages/components/LegacyMessageAttachments"; +import NavigationService from "../../../navigation/NavigationService"; +import PN_ROUTES from "../navigation/routes"; +import { PNMessage } from "../store/types/types"; +import { NotificationPaymentInfo } from "../../../../definitions/pn/NotificationPaymentInfo"; +import { trackPNAttachmentOpening } from "../analytics"; +import { DSFullWidthComponent } from "../../design-system/components/DSFullWidthComponent"; +import StatusContent from "../../../components/SectionStatus/StatusContent"; +import { + getStatusTextColor, + statusColorMap, + statusIconMap +} from "../../../components/SectionStatus"; +import { LevelEnum } from "../../../../definitions/content/SectionStatus"; +import { ATTACHMENT_CATEGORY } from "../../messages/types/attachmentCategory"; +import { maxVisiblePaymentCountGenerator } from "../utils"; +import { LegacyMessageDetailsContent } from "./LegacyMessageDetailsContent"; +import { MessageDetailsHeader } from "./MessageDetailsHeader"; +import { MessageDetailsSection } from "./MessageDetailsSection"; +import { MessageTimeline } from "./MessageTimeline"; +import { MessageTimelineCTA } from "./MessageTimelineCTA"; +import { MessageF24 } from "./MessageF24"; +import { MessagePayments } from "./MessagePayments"; +import { MessageFooter } from "./MessageFooter"; +import { MessagePaymentBottomSheet } from "./MessagePaymentBottomSheet"; + +type Props = Readonly<{ + messageId: UIMessageId; + message: PNMessage; + service: ServicePublic | undefined; + payments: ReadonlyArray | undefined; +}>; + +export const LegacyMessageDetails = ({ + message, + messageId, + service, + payments +}: Props) => { + const viewRef = createRef(); + const presentPaymentsBottomSheetRef = useRef<() => void>(); + const frontendUrl = useIOSelector(pnFrontendUrlSelector); + + const partitionedAttachments = pipe( + message.attachments, + O.fromNullable, + O.getOrElse>(() => []), + RA.partition(attachment => attachment.category === ATTACHMENT_CATEGORY.F24) + ); + + const f24List = SEP.right(partitionedAttachments); + const attachmentList = SEP.left(partitionedAttachments); + + const isCancelled = message.isCancelled ?? false; + const completedPaymentNoticeCodes = isCancelled + ? message.completedPayments + : undefined; + + const openAttachment = useCallback( + (attachment: UIAttachment) => { + trackPNAttachmentOpening(attachment.category); + NavigationService.navigate(PN_ROUTES.MESSAGE_ATTACHMENT, { + messageId, + attachmentId: attachment.id, + category: attachment.category + }); + }, + [messageId] + ); + + const maxVisiblePaymentCount = maxVisiblePaymentCountGenerator(); + const scrollViewRef = React.createRef(); + + return ( + <> + + {service && } + + + {isCancelled && ( + <> + + + + {I18n.t("features.pn.details.cancelledMessage.body")} + + + + )} + + {RA.isNonEmpty(attachmentList) && ( + + + + )} + + + {!isCancelled && RA.isNonEmpty(f24List) ? ( + <> + + + + ) : null} + + + clipboardSetStringWithFeedback(message.iun)} + accessibilityLabel={I18n.t("features.pn.details.infoSection.iun")} + label={I18n.t("features.pn.details.infoSection.iun")} + /> +
+ {I18n.t("features.pn.details.timeline.title")} +
+ + { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }} + /> + {frontendUrl.length > 0 && } +
+
+ + {payments && !isCancelled && ( + + )} + + + + ); +}; diff --git a/ts/features/pn/components/LegacyMessageDetailsContent.tsx b/ts/features/pn/components/LegacyMessageDetailsContent.tsx new file mode 100644 index 00000000000..59cb0f34fbe --- /dev/null +++ b/ts/features/pn/components/LegacyMessageDetailsContent.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { View, ViewProps, StyleSheet } from "react-native"; +import { Body } from "../../../components/core/typography/Body"; +import { H1 } from "../../../components/core/typography/H1"; +import { H2 } from "../../../components/core/typography/H2"; +import { PNMessage } from "../store/types/types"; +import customVariables from "../../../theme/variables"; +import { isStringNullyOrEmpty } from "../../../utils/strings"; + +const styles = StyleSheet.create({ + subject: { + marginTop: customVariables.spacerExtrasmallHeight + }, + abstract: { + marginTop: customVariables.spacerExtrasmallHeight + } +}); + +type Props = Readonly<{ message: PNMessage }> & ViewProps; + +export const LegacyMessageDetailsContent = (props: Props) => ( + + {!isStringNullyOrEmpty(props.message.senderDenomination) && ( +

{props.message.senderDenomination}

+ )} +

{props.message.subject}

+ {!isStringNullyOrEmpty(props.message.abstract) && ( + {props.message.abstract} + )} +
+); diff --git a/ts/features/pn/components/MessageDetails.tsx b/ts/features/pn/components/MessageDetails.tsx index a2370ecabd8..d6f25513ce4 100644 --- a/ts/features/pn/components/MessageDetails.tsx +++ b/ts/features/pn/components/MessageDetails.tsx @@ -1,193 +1,27 @@ -import React, { useCallback, createRef, useRef } from "react"; -import { pipe } from "fp-ts/lib/function"; -import * as O from "fp-ts/lib/Option"; -import * as RA from "fp-ts/lib/ReadonlyArray"; -import * as SEP from "fp-ts/lib/Separated"; -import { View } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; -import { - IOVisualCostants, - ListItemInfoCopy, - VSpacer -} from "@pagopa/io-app-design-system"; +import React from "react"; +import { ScrollView } from "react-native"; import { ServicePublic } from "../../../../definitions/backend/ServicePublic"; -import { H5 } from "../../../components/core/typography/H5"; -import I18n from "../../../i18n"; -import { useIOSelector } from "../../../store/hooks"; -import { pnFrontendUrlSelector } from "../../../store/reducers/backendStatus"; -import { UIAttachment, UIMessageId } from "../../messages/types"; -import { clipboardSetStringWithFeedback } from "../../../utils/clipboard"; -import { LegacyMessageAttachments } from "../../messages/components/LegacyMessageAttachments"; -import NavigationService from "../../../navigation/NavigationService"; -import PN_ROUTES from "../navigation/routes"; +import { UIMessageId } from "../../messages/types"; import { PNMessage } from "../store/types/types"; import { NotificationPaymentInfo } from "../../../../definitions/pn/NotificationPaymentInfo"; -import { trackPNAttachmentOpening } from "../analytics"; -import { DSFullWidthComponent } from "../../design-system/components/DSFullWidthComponent"; -import StatusContent from "../../../components/SectionStatus/StatusContent"; -import { - getStatusTextColor, - statusColorMap, - statusIconMap -} from "../../../components/SectionStatus"; -import { LevelEnum } from "../../../../definitions/content/SectionStatus"; -import { ATTACHMENT_CATEGORY } from "../../messages/types/attachmentCategory"; -import { maxVisiblePaymentCountGenerator } from "../utils"; +import { MessageDetailHeader } from "../../messages/components/MessageDetail/MessageDetailHeader"; import { MessageDetailsContent } from "./MessageDetailsContent"; -import { MessageDetailsHeader } from "./MessageDetailsHeader"; -import { MessageDetailsSection } from "./MessageDetailsSection"; -import { MessageTimeline } from "./MessageTimeline"; -import { MessageTimelineCTA } from "./MessageTimelineCTA"; -import { MessageF24 } from "./MessageF24"; -import { MessagePayments } from "./MessagePayments"; -import { MessageFooter } from "./MessageFooter"; -import { MessagePaymentBottomSheet } from "./MessagePaymentBottomSheet"; -type Props = Readonly<{ - messageId: UIMessageId; +type MessageDetailsProps = { message: PNMessage; - service: ServicePublic | undefined; - payments: ReadonlyArray | undefined; -}>; - -export const MessageDetails = ({ - message, - messageId, - service, - payments -}: Props) => { - const viewRef = createRef(); - const presentPaymentsBottomSheetRef = useRef<() => void>(); - const frontendUrl = useIOSelector(pnFrontendUrlSelector); - - const partitionedAttachments = pipe( - message.attachments, - O.fromNullable, - O.getOrElse>(() => []), - RA.partition(attachment => attachment.category === ATTACHMENT_CATEGORY.F24) - ); - - const f24List = SEP.right(partitionedAttachments); - const attachmentList = SEP.left(partitionedAttachments); - - const isCancelled = message.isCancelled ?? false; - const completedPaymentNoticeCodes = isCancelled - ? message.completedPayments - : undefined; - - const openAttachment = useCallback( - (attachment: UIAttachment) => { - trackPNAttachmentOpening(attachment.category); - NavigationService.navigate(PN_ROUTES.MESSAGE_ATTACHMENT, { - messageId, - attachmentId: attachment.id, - category: attachment.category - }); - }, - [messageId] - ); - - const maxVisiblePaymentCount = maxVisiblePaymentCountGenerator(); - const scrollViewRef = React.createRef(); - - return ( - <> - - {service && } - - - {isCancelled && ( - <> - - - - {I18n.t("features.pn.details.cancelledMessage.body")} - - - - )} - - {RA.isNonEmpty(attachmentList) && ( - - - - )} - - - {!isCancelled && RA.isNonEmpty(f24List) ? ( - <> - - - - ) : null} - - - clipboardSetStringWithFeedback(message.iun)} - accessibilityLabel={I18n.t("features.pn.details.infoSection.iun")} - label={I18n.t("features.pn.details.infoSection.iun")} - /> -
- {I18n.t("features.pn.details.timeline.title")} -
- - { - scrollViewRef.current?.scrollToEnd({ animated: true }); - }} - /> - {frontendUrl.length > 0 && } -
-
- - {payments && !isCancelled && ( - - )} - - - - ); + messageId: UIMessageId; + service?: ServicePublic; + payments?: ReadonlyArray; }; + +export const MessageDetails = ({ message, service }: MessageDetailsProps) => ( + + + + +); diff --git a/ts/features/pn/components/MessageDetailsContent.tsx b/ts/features/pn/components/MessageDetailsContent.tsx index 21b33cfef0c..96cbdbbe3ad 100644 --- a/ts/features/pn/components/MessageDetailsContent.tsx +++ b/ts/features/pn/components/MessageDetailsContent.tsx @@ -1,31 +1,21 @@ import React from "react"; -import { View, ViewProps, StyleSheet } from "react-native"; -import { Body } from "../../../components/core/typography/Body"; -import { H1 } from "../../../components/core/typography/H1"; -import { H2 } from "../../../components/core/typography/H2"; -import { PNMessage } from "../store/types/types"; -import customVariables from "../../../theme/variables"; -import { isStringNullyOrEmpty } from "../../../utils/strings"; +import { Body, ContentWrapper, VSpacer } from "@pagopa/io-app-design-system"; -const styles = StyleSheet.create({ - subject: { - marginTop: customVariables.spacerExtrasmallHeight - }, - abstract: { - marginTop: customVariables.spacerExtrasmallHeight - } -}); +type MessageDetailsContentProps = { + abstract?: string; +}; -type Props = Readonly<{ message: PNMessage }> & ViewProps; +export const MessageDetailsContent = ({ + abstract +}: MessageDetailsContentProps) => { + if (abstract === undefined) { + return null; + } -export const MessageDetailsContent = (props: Props) => ( - - {!isStringNullyOrEmpty(props.message.senderDenomination) && ( -

{props.message.senderDenomination}

- )} -

{props.message.subject}

- {!isStringNullyOrEmpty(props.message.abstract) && ( - {props.message.abstract} - )} -
-); + return ( + + + {abstract} + + ); +}; diff --git a/ts/features/pn/components/__test__/LegacyMessageDetails.test.tsx b/ts/features/pn/components/__test__/LegacyMessageDetails.test.tsx new file mode 100644 index 00000000000..a7ec40d4d30 --- /dev/null +++ b/ts/features/pn/components/__test__/LegacyMessageDetails.test.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import * as pot from "@pagopa/ts-commons/lib/pot"; +import { act, fireEvent } from "@testing-library/react-native"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../store/actions/application"; +import { appReducer } from "../../../../store/reducers"; +import { LegacyMessageDetails } from "../LegacyMessageDetails"; +import { GlobalState } from "../../../../store/reducers/types"; +import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import PN_ROUTES from "../../navigation/routes"; +import { UIAttachment, UIMessageId } from "../../../messages/types"; +import { PNMessage } from "../../store/types/types"; +import { Download } from "../../../messages/store/reducers/downloads"; +import { NotificationRecipient } from "../../../../../definitions/pn/NotificationRecipient"; +import { ATTACHMENT_CATEGORY } from "../../../messages/types/attachmentCategory"; + +const mockedOnAttachmentSelect = jest.fn(); + +jest.mock("../../../messages/hooks/useAttachmentDownload", () => ({ + useAttachmentDownload: ( + _attachment: UIAttachment, + _openPreview: (attachment: UIAttachment) => void + ) => ({ + onAttachmentSelect: mockedOnAttachmentSelect, + downloadPot: { kind: "PotNone" } as pot.Pot + }) +})); + +describe("LegacyMessageDetails component", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should not show the cancelled banner when the PN message is not cancelled", () => { + const { component } = renderComponent( + generateComponentProperties(generatePnMessage()) + ); + expect(component.queryByTestId("PnCancelledMessageBanner")).toBeFalsy(); + }); + + it("every attachment item should have a full opaque style when the PN message is not cancelled", () => { + const { component } = renderComponent( + generateComponentProperties(generatePnMessage()) + ); + const messageAttachmentComponents = component.queryAllByTestId( + "MessageAttachmentTouchable" + ); + expect(messageAttachmentComponents.length).toBe(2); + + messageAttachmentComponents.forEach(messageAttachmentComponent => { + const opacity = messageAttachmentComponent?.props.style.opacity; + expect(opacity).toBe(1.0); + }); + }); + + it("should show the cancelled banner when the PN message is cancelled", () => { + const { component } = renderComponent( + generateComponentProperties({ + ...generatePnMessage(), + isCancelled: true + }) + ); + expect(component.queryByTestId("PnCancelledMessageBanner")).toBeDefined(); + }); + + it("every attachment item should have a semi-transparent style when the PN message is cancelled", () => { + const { component } = renderComponent( + generateComponentProperties({ + ...generatePnMessage(), + isCancelled: true + }) + ); + const messageAttachmentComponents = component.queryAllByTestId( + "MessageAttachmentTouchable" + ); + expect(messageAttachmentComponents.length).toBe(2); + + messageAttachmentComponents.forEach(messageAttachmentComponent => { + const opacity = messageAttachmentComponent?.props.style.opacity; + expect(opacity).toBe(0.5); + }); + }); + + it("every attachment item should handle input when the PN message not is cancelled", async () => { + const { component } = renderComponent( + generateComponentProperties(generatePnMessage()) + ); + const messageAttachmentComponents = component.queryAllByTestId( + "MessageAttachmentTouchable" + ); + expect(messageAttachmentComponents.length).toBe(2); + await act(() => { + messageAttachmentComponents.forEach(messageAttachmentComponent => + fireEvent.press(messageAttachmentComponent) + ); + }); + expect(mockedOnAttachmentSelect).toHaveBeenCalledTimes( + messageAttachmentComponents.length + ); + }); + + it("every attachment item should not handle input when the PN message is cancelled", async () => { + const { component } = renderComponent( + generateComponentProperties({ ...generatePnMessage(), isCancelled: true }) + ); + const messageAttachmentComponents = component.queryAllByTestId( + "MessageAttachmentTouchable" + ); + expect(messageAttachmentComponents.length).toBe(2); + // eslint-disable-next-line sonarjs/no-identical-functions + await act(() => { + messageAttachmentComponents.forEach(messageAttachmentComponent => + fireEvent.press(messageAttachmentComponent) + ); + }); + expect(mockedOnAttachmentSelect).toHaveBeenCalledTimes(0); + }); + + it("should NOT render the F24 section if there are no multiple F24", () => { + const { component } = renderComponent( + generateComponentProperties(generatePnMessage()) + ); + expect(component.queryByTestId("pn-message-f24-section")).toBeFalsy(); + }); +}); + +const generateTestMessageId = () => "00000000000000000000000004" as UIMessageId; +const generateTestFiscalCode = () => "AAABBB00A00A000A"; +const generatePnMessage = (): PNMessage => ({ + created_at: new Date(), + iun: "731143-7-0317-8200-0", + subject: "This is the message subject", + senderDenomination: "Sender denomination", + abstract: "Message abstract", + notificationStatusHistory: [], + recipients: [ + { + recipientType: "-", + taxId: generateTestFiscalCode(), + denomination: "AaAaAa BbBbBb", + payment: { + noticeCode: "026773337463073118", + creditorTaxId: "00000000009" + } + } + ] as Array, + attachments: [ + { + messageId: generateTestMessageId(), + id: "1", + displayName: "A First Attachment", + contentType: "application/pdf", + category: ATTACHMENT_CATEGORY.DOCUMENT, + resourceUrl: { href: "/resource/attachment1.pdf" } + }, + { + messageId: generateTestMessageId(), + id: "2", + displayName: "A Second Attachment", + contentType: "application/pdf", + category: ATTACHMENT_CATEGORY.DOCUMENT, + resourceUrl: { href: "/resource/attachment2.pdf" } + } + ] as Array +}); +const generateComponentProperties = (pnMessage: PNMessage) => ({ + payments: undefined, + messageId: generateTestMessageId(), + message: pnMessage, + service: undefined +}); + +const renderComponent = ( + props: React.ComponentProps +) => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + return { + component: renderScreenWithNavigationStoreContext( + () => , + PN_ROUTES.MESSAGE_DETAILS, + {}, + store + ), + store + }; +}; diff --git a/ts/features/pn/components/__test__/MessageDetails.test.tsx b/ts/features/pn/components/__test__/MessageDetails.test.tsx index 11a7c0e7b65..b6c8687b84c 100644 --- a/ts/features/pn/components/__test__/MessageDetails.test.tsx +++ b/ts/features/pn/components/__test__/MessageDetails.test.tsx @@ -1,171 +1,38 @@ import React from "react"; -import * as pot from "@pagopa/ts-commons/lib/pot"; -import { act, fireEvent } from "@testing-library/react-native"; -import { createStore } from "redux"; +import configureMockStore from "redux-mock-store"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; import { applicationChangeState } from "../../../../store/actions/application"; import { appReducer } from "../../../../store/reducers"; import { MessageDetails } from "../MessageDetails"; import { GlobalState } from "../../../../store/reducers/types"; import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; -import PN_ROUTES from "../../navigation/routes"; -import { UIAttachment, UIMessageId } from "../../../messages/types"; import { PNMessage } from "../../store/types/types"; -import { Download } from "../../../messages/store/reducers/downloads"; -import { NotificationRecipient } from "../../../../../definitions/pn/NotificationRecipient"; -import { ATTACHMENT_CATEGORY } from "../../../messages/types/attachmentCategory"; +import { thirdPartyMessage } from "../../__mocks__/message"; +import { toPNMessage } from "../../store/types/transformers"; +import { UIMessageId } from "../../../messages/types"; -const mockedOnAttachmentSelect = jest.fn(); - -jest.mock("../../../messages/hooks/useAttachmentDownload", () => ({ - useAttachmentDownload: ( - _attachment: UIAttachment, - _openPreview: (attachment: UIAttachment) => void - ) => ({ - onAttachmentSelect: mockedOnAttachmentSelect, - downloadPot: { kind: "PotNone" } as pot.Pot - }) -})); +const pnMessage = pipe(thirdPartyMessage, toPNMessage, O.toUndefined); describe("MessageDetails component", () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it("should not show the cancelled banner when the PN message is not cancelled", () => { - const { component } = renderComponent( - generateComponentProperties(generatePnMessage()) - ); - expect(component.queryByTestId("PnCancelledMessageBanner")).toBeFalsy(); - }); - - it("every attachment item should have a full opaque style when the PN message is not cancelled", () => { - const { component } = renderComponent( - generateComponentProperties(generatePnMessage()) - ); - const messageAttachmentComponents = component.queryAllByTestId( - "MessageAttachmentTouchable" - ); - expect(messageAttachmentComponents.length).toBe(2); - - messageAttachmentComponents.forEach(messageAttachmentComponent => { - const opacity = messageAttachmentComponent?.props.style.opacity; - expect(opacity).toBe(1.0); - }); - }); - - it("should show the cancelled banner when the PN message is cancelled", () => { + it("should match the snapshot", () => { const { component } = renderComponent( - generateComponentProperties({ - ...generatePnMessage(), - isCancelled: true - }) + generateComponentProperties( + thirdPartyMessage.id as UIMessageId, + pnMessage! + ) ); - expect(component.queryByTestId("PnCancelledMessageBanner")).toBeDefined(); - }); - - it("every attachment item should have a semi-transparent style when the PN message is cancelled", () => { - const { component } = renderComponent( - generateComponentProperties({ - ...generatePnMessage(), - isCancelled: true - }) - ); - const messageAttachmentComponents = component.queryAllByTestId( - "MessageAttachmentTouchable" - ); - expect(messageAttachmentComponents.length).toBe(2); - - messageAttachmentComponents.forEach(messageAttachmentComponent => { - const opacity = messageAttachmentComponent?.props.style.opacity; - expect(opacity).toBe(0.5); - }); - }); - - it("every attachment item should handle input when the PN message not is cancelled", async () => { - const { component } = renderComponent( - generateComponentProperties(generatePnMessage()) - ); - const messageAttachmentComponents = component.queryAllByTestId( - "MessageAttachmentTouchable" - ); - expect(messageAttachmentComponents.length).toBe(2); - await act(() => { - messageAttachmentComponents.forEach(messageAttachmentComponent => - fireEvent.press(messageAttachmentComponent) - ); - }); - expect(mockedOnAttachmentSelect).toHaveBeenCalledTimes( - messageAttachmentComponents.length - ); - }); - - it("every attachment item should not handle input when the PN message is cancelled", async () => { - const { component } = renderComponent( - generateComponentProperties({ ...generatePnMessage(), isCancelled: true }) - ); - const messageAttachmentComponents = component.queryAllByTestId( - "MessageAttachmentTouchable" - ); - expect(messageAttachmentComponents.length).toBe(2); - // eslint-disable-next-line sonarjs/no-identical-functions - await act(() => { - messageAttachmentComponents.forEach(messageAttachmentComponent => - fireEvent.press(messageAttachmentComponent) - ); - }); - expect(mockedOnAttachmentSelect).toHaveBeenCalledTimes(0); - }); - - it("should NOT render the F24 section if there are no multiple F24", () => { - const { component } = renderComponent( - generateComponentProperties(generatePnMessage()) - ); - expect(component.queryByTestId("pn-message-f24-section")).toBeFalsy(); + expect(component).toMatchSnapshot(); }); }); -const generateTestMessageId = () => "00000000000000000000000004" as UIMessageId; -const generateTestFiscalCode = () => "AAABBB00A00A000A"; -const generatePnMessage = (): PNMessage => ({ - iun: "731143-7-0317-8200-0", - subject: "This is the message subject", - senderDenomination: "Sender denomination", - abstract: "Message abstract", - notificationStatusHistory: [], - recipients: [ - { - recipientType: "-", - taxId: generateTestFiscalCode(), - denomination: "AaAaAa BbBbBb", - payment: { - noticeCode: "026773337463073118", - creditorTaxId: "00000000009" - } - } - ] as Array, - attachments: [ - { - messageId: generateTestMessageId(), - id: "1", - displayName: "A First Attachment", - contentType: "application/pdf", - category: ATTACHMENT_CATEGORY.DOCUMENT, - resourceUrl: { href: "/resource/attachment1.pdf" } - }, - { - messageId: generateTestMessageId(), - id: "2", - displayName: "A Second Attachment", - contentType: "application/pdf", - category: ATTACHMENT_CATEGORY.DOCUMENT, - resourceUrl: { href: "/resource/attachment2.pdf" } - } - ] as Array -}); -const generateComponentProperties = (pnMessage: PNMessage) => ({ +const generateComponentProperties = ( + messageId: UIMessageId, + message: PNMessage +) => ({ + messageId, + message, payments: undefined, - messageId: generateTestMessageId(), - message: pnMessage, service: undefined }); @@ -173,12 +40,13 @@ const renderComponent = ( props: React.ComponentProps ) => { const globalState = appReducer(undefined, applicationChangeState("active")); - const store = createStore(appReducer, globalState as any); + const mockStore = configureMockStore(); + const store: ReturnType = mockStore(globalState); return { component: renderScreenWithNavigationStoreContext( () => , - PN_ROUTES.MESSAGE_DETAILS, + "DUMMY_ROUTE", {}, store ), diff --git a/ts/features/pn/components/__test__/MessageDetailsContent.test.tsx b/ts/features/pn/components/__test__/MessageDetailsContent.test.tsx new file mode 100644 index 00000000000..31e5a7c3603 --- /dev/null +++ b/ts/features/pn/components/__test__/MessageDetailsContent.test.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import { MessageDetailsContent } from "../MessageDetailsContent"; + +describe("MessageDetailsContent component", () => { + it("should match the snapshot when abstract is defined", () => { + const component = render(); + expect(component.toJSON()).toMatchSnapshot(); + }); + + it("should match the snapshot when abstract is not defined", () => { + const component = render(); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/ts/features/pn/components/__test__/__snapshots__/MessageDetails.test.tsx.snap b/ts/features/pn/components/__test__/__snapshots__/MessageDetails.test.tsx.snap new file mode 100644 index 00000000000..45785dc4e5f --- /dev/null +++ b/ts/features/pn/components/__test__/__snapshots__/MessageDetails.test.tsx.snap @@ -0,0 +1,449 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageDetails component should match the snapshot 1`] = ` + + + + + + + + + + + + + DUMMY_ROUTE + + + + + + + + + + + + + + + + + + + + + ######## subject ######## + + + + 01 Jan 2020, 00:00 + + + + + + + + ######## abstract ######## + + + + + + + + + + + + + + +`; diff --git a/ts/features/pn/components/__test__/__snapshots__/MessageDetailsContent.test.tsx.snap b/ts/features/pn/components/__test__/__snapshots__/MessageDetailsContent.test.tsx.snap new file mode 100644 index 00000000000..19f593ad32a --- /dev/null +++ b/ts/features/pn/components/__test__/__snapshots__/MessageDetailsContent.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageDetailsContent component should match the snapshot when abstract is defined 1`] = ` + + + + abstract + + +`; + +exports[`MessageDetailsContent component should match the snapshot when abstract is not defined 1`] = `null`; diff --git a/ts/features/pn/navigation/navigator.tsx b/ts/features/pn/navigation/navigator.tsx index 5b6a533ca60..d837d801971 100644 --- a/ts/features/pn/navigation/navigator.tsx +++ b/ts/features/pn/navigation/navigator.tsx @@ -2,30 +2,50 @@ import * as React from "react"; import { createStackNavigator } from "@react-navigation/stack"; import { isGestureEnabled } from "../../../utils/navigation"; import { MessageDetailsScreen } from "../screens/MessageDetailsScreen"; +import { LegacyMessageDetailsScreen } from "../screens/LegacyMessageDetailsScreen"; import { AttachmentPreviewScreen } from "../screens/AttachmentPreviewScreen"; import { PaidPaymentScreen } from "../screens/PaidPaymentScreen"; +import { useIOSelector } from "../../../store/hooks"; +import { isDesignSystemEnabledSelector } from "../../../store/reducers/persistedPreferences"; import { PnParamsList } from "./params"; import PN_ROUTES from "./routes"; const Stack = createStackNavigator(); -export const PnStackNavigator = () => ( - - - - - -); +export const PnStackNavigator = () => { + const isDesignSystemEnabled = useIOSelector(isDesignSystemEnabledSelector); + + return ( + + + + + + ); +}; diff --git a/ts/features/pn/screens/LegacyMessageDetailsScreen.tsx b/ts/features/pn/screens/LegacyMessageDetailsScreen.tsx new file mode 100644 index 00000000000..7d2ad7d08a2 --- /dev/null +++ b/ts/features/pn/screens/LegacyMessageDetailsScreen.tsx @@ -0,0 +1,132 @@ +import * as pot from "@pagopa/ts-commons/lib/pot"; +import { pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import React from "react"; +import { SafeAreaView } from "react-native"; +import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useStore } from "react-redux"; +import { IOStyles } from "../../../components/core/variables/IOStyles"; +import BaseScreenComponent from "../../../components/screens/BaseScreenComponent"; +import I18n from "../../../i18n"; +import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; +import { useIODispatch, useIOSelector } from "../../../store/hooks"; +import { serviceByIdSelector } from "../../../store/reducers/entities/services/servicesById"; +import { emptyContextualHelp } from "../../../utils/emptyContextualHelp"; +import { useOnFirstRender } from "../../../utils/hooks/useOnFirstRender"; +import { LegacyMessageDetails } from "../components/LegacyMessageDetails"; +import { PnParamsList } from "../navigation/params"; +import { pnMessageFromIdSelector } from "../store/reducers"; +import { cancelPreviousAttachmentDownload } from "../../messages/store/actions"; +import { profileFiscalCodeSelector } from "../../../store/reducers/profile"; +import { + containsF24FromPNMessagePot, + isCancelledFromPNMessagePot, + paymentsFromPNMessagePot +} from "../utils"; +import { trackPNUxSuccess } from "../analytics"; +import { isStrictSome } from "../../../utils/pot"; +import { + cancelPaymentStatusTracking, + cancelQueuedPaymentUpdates, + clearSelectedPayment, + startPaymentStatusTracking, + updatePaymentForMessage +} from "../store/actions"; +import { GlobalState } from "../../../store/reducers/types"; +import { selectedPaymentIdSelector } from "../store/reducers/payments"; +import { InfoScreenComponent } from "../../../components/infoScreen/InfoScreenComponent"; +import { renderInfoRasterImage } from "../../../components/infoScreen/imageRendering"; +import genericErrorIcon from "../../../../img/wallet/errors/generic-error-icon.png"; + +export const LegacyMessageDetailsScreen = ( + props: IOStackNavigationRouteProps +): React.ReactElement => { + const { messageId, serviceId, firstTimeOpening } = props.route.params; + + const dispatch = useIODispatch(); + const navigation = useNavigation(); + + const service = pot.toUndefined( + useIOSelector(state => serviceByIdSelector(state, serviceId)) + ); + + const currentFiscalCode = useIOSelector(profileFiscalCodeSelector); + const messagePot = useIOSelector(state => + pnMessageFromIdSelector(state, messageId) + ); + const payments = paymentsFromPNMessagePot(currentFiscalCode, messagePot); + + const customGoBack = React.useCallback(() => { + dispatch(cancelPreviousAttachmentDownload()); + dispatch(cancelQueuedPaymentUpdates()); + dispatch(cancelPaymentStatusTracking()); + navigation.goBack(); + }, [dispatch, navigation]); + + useOnFirstRender(() => { + dispatch(startPaymentStatusTracking(messageId)); + + if (isStrictSome(messagePot)) { + const paymentCount = payments?.length ?? 0; + const isCancelled = isCancelledFromPNMessagePot(messagePot); + const containsF24 = containsF24FromPNMessagePot(messagePot); + + trackPNUxSuccess( + paymentCount, + firstTimeOpening, + isCancelled, + containsF24 + ); + } + }); + + const store = useStore(); + useFocusEffect( + React.useCallback(() => { + const globalState = store.getState() as GlobalState; + const selectedPaymentId = selectedPaymentIdSelector(globalState); + if (selectedPaymentId) { + dispatch(clearSelectedPayment()); + dispatch( + updatePaymentForMessage.request({ + messageId, + paymentId: selectedPaymentId + }) + ); + } + }, [dispatch, messageId, store]) + ); + + return ( + + + {pipe( + messagePot, + pot.toOption, + O.flatten, + O.fold( + () => ( + + ), + message => ( + + ) + ) + )} + + + ); +}; diff --git a/ts/features/pn/screens/MessageDetailsScreen.tsx b/ts/features/pn/screens/MessageDetailsScreen.tsx index bd84320d4a5..cc4ff8e8dbe 100644 --- a/ts/features/pn/screens/MessageDetailsScreen.tsx +++ b/ts/features/pn/screens/MessageDetailsScreen.tsx @@ -1,19 +1,19 @@ +import React, { useCallback } from "react"; +import { + RouteProp, + useFocusEffect, + useNavigation, + useRoute +} from "@react-navigation/native"; +import { useStore } from "react-redux"; import * as pot from "@pagopa/ts-commons/lib/pot"; import { pipe } from "fp-ts/lib/function"; import * as O from "fp-ts/lib/Option"; -import React from "react"; -import { SafeAreaView } from "react-native"; -import { useFocusEffect, useNavigation } from "@react-navigation/native"; -import { useStore } from "react-redux"; -import { IOStyles } from "../../../components/core/variables/IOStyles"; -import BaseScreenComponent from "../../../components/screens/BaseScreenComponent"; import { ServiceId } from "../../../../definitions/backend/ServiceId"; import I18n from "../../../i18n"; -import { IOStackNavigationRouteProps } from "../../../navigation/params/AppParamsList"; import { useIODispatch, useIOSelector } from "../../../store/hooks"; import { UIMessageId } from "../../messages/types"; import { serviceByIdSelector } from "../../../store/reducers/entities/services/servicesById"; -import { emptyContextualHelp } from "../../../utils/emptyContextualHelp"; import { useOnFirstRender } from "../../../utils/hooks/useOnFirstRender"; import { MessageDetails } from "../components/MessageDetails"; import { PnParamsList } from "../navigation/params"; @@ -36,40 +36,48 @@ import { } from "../store/actions"; import { GlobalState } from "../../../store/reducers/types"; import { selectedPaymentIdSelector } from "../store/reducers/payments"; -import { InfoScreenComponent } from "../../../components/infoScreen/InfoScreenComponent"; -import { renderInfoRasterImage } from "../../../components/infoScreen/imageRendering"; -import genericErrorIcon from "../../../../img/wallet/errors/generic-error-icon.png"; +import { useHeaderSecondLevel } from "../../../hooks/useHeaderSecondLevel"; +import { OperationResultScreenContent } from "../../../components/screens/OperationResultScreenContent"; -export type MessageDetailsScreenNavigationParams = Readonly<{ +export type MessageDetailsScreenNavigationParams = { messageId: UIMessageId; serviceId: ServiceId; firstTimeOpening: boolean; -}>; +}; -export const MessageDetailsScreen = ( - props: IOStackNavigationRouteProps -): React.ReactElement => { - const { messageId, serviceId, firstTimeOpening } = props.route.params; +type MessageDetailsRouteProps = RouteProp< + PnParamsList, + "PN_ROUTES_MESSAGE_DETAILS" +>; +export const MessageDetailsScreen = () => { const dispatch = useIODispatch(); const navigation = useNavigation(); + const route = useRoute(); + + const { messageId, serviceId, firstTimeOpening } = route.params; const service = pot.toUndefined( useIOSelector(state => serviceByIdSelector(state, serviceId)) ); - const currentFiscalCode = useIOSelector(profileFiscalCodeSelector); const messagePot = useIOSelector(state => pnMessageFromIdSelector(state, messageId) ); const payments = paymentsFromPNMessagePot(currentFiscalCode, messagePot); - const customGoBack = React.useCallback(() => { + const goBack = useCallback(() => { dispatch(cancelPreviousAttachmentDownload()); dispatch(cancelQueuedPaymentUpdates()); dispatch(cancelPaymentStatusTracking()); navigation.goBack(); - }, [dispatch, navigation]); + }, []); + + useHeaderSecondLevel({ + title: "", + goBack, + supportRequest: true + }); useOnFirstRender(() => { dispatch(startPaymentStatusTracking(messageId)); @@ -90,7 +98,7 @@ export const MessageDetailsScreen = ( const store = useStore(); useFocusEffect( - React.useCallback(() => { + useCallback(() => { const globalState = store.getState() as GlobalState; const selectedPaymentId = selectedPaymentIdSelector(globalState); if (selectedPaymentId) { @@ -106,35 +114,29 @@ export const MessageDetailsScreen = ( ); return ( - - - {pipe( - messagePot, - pot.toOption, - O.flatten, - O.fold( - () => ( - - ), - message => ( - - ) + <> + {pipe( + messagePot, + pot.toOption, + O.flatten, + O.fold( + () => ( + + ), + message => ( + ) - )} - - + ) + )} + ); }; diff --git a/ts/features/pn/screens/__test__/MessageDetailsScreen.test.tsx b/ts/features/pn/screens/__test__/MessageDetailsScreen.test.tsx new file mode 100644 index 00000000000..0aa4268dca0 --- /dev/null +++ b/ts/features/pn/screens/__test__/MessageDetailsScreen.test.tsx @@ -0,0 +1,83 @@ +import configureMockStore from "redux-mock-store"; +import { Action, Store } from "redux"; +import PN_ROUTES from "../../navigation/routes"; +import { GlobalState } from "../../../../store/reducers/types"; +import { appReducer } from "../../../../store/reducers"; +import { MessageDetailsScreen } from "../MessageDetailsScreen"; +import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; +import { reproduceSequence } from "../../../../utils/tests"; +import { + loadMessageById, + loadMessageDetails, + loadThirdPartyMessage +} from "../../../messages/store/actions"; +import { + toUIMessage, + toUIMessageDetails +} from "../../../messages/store/reducers/transformers"; +import { message_1 } from "../../../messages/__mocks__/message"; +import { loadServiceDetail } from "../../../../store/actions/services"; +import { service_1 } from "../../../messages/__mocks__/messages"; +import { UIMessageId } from "../../../messages/types"; +import { applicationChangeState } from "../../../../store/actions/application"; +import { thirdPartyMessage } from "../../__mocks__/message"; + +describe("MessageDetailsScreen", () => { + it("should match the snapshot when there is an error", () => { + const sequenceOfActions: ReadonlyArray = [ + applicationChangeState("active") + ]; + + const state: GlobalState = reproduceSequence( + {} as GlobalState, + appReducer, + sequenceOfActions + ); + const mockStore = configureMockStore(); + const store: Store = mockStore(state); + + const { component } = renderComponent(store); + expect(component).toMatchSnapshot(); + }); + + it("should match the snapshot when everything went fine", () => { + const sequenceOfActions: ReadonlyArray = [ + applicationChangeState("active"), + loadMessageById.success(toUIMessage(message_1)), + loadServiceDetail.success(service_1), + loadMessageDetails.success(toUIMessageDetails(message_1)), + loadThirdPartyMessage.success({ + id: message_1.id as UIMessageId, + content: thirdPartyMessage + }) + ]; + + const state: GlobalState = reproduceSequence( + {} as GlobalState, + appReducer, + sequenceOfActions + ); + const mockStore = configureMockStore(); + const store: Store = mockStore(state); + + const { component } = renderComponent(store); + expect(component).toMatchSnapshot(); + }); +}); + +const renderComponent = (store: Store) => { + const { id, sender_service_id } = message_1; + + return { + component: renderScreenWithNavigationStoreContext( + MessageDetailsScreen, + PN_ROUTES.MESSAGE_DETAILS, + { + firstTimeOpening: false, + messageId: id, + serviceId: sender_service_id + }, + store + ) + }; +}; diff --git a/ts/features/pn/screens/__test__/__snapshots__/MessageDetailsScreen.test.tsx.snap b/ts/features/pn/screens/__test__/__snapshots__/MessageDetailsScreen.test.tsx.snap new file mode 100644 index 00000000000..48edcca56d3 --- /dev/null +++ b/ts/features/pn/screens/__test__/__snapshots__/MessageDetailsScreen.test.tsx.snap @@ -0,0 +1,1372 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MessageDetailsScreen should match the snapshot when everything went fine 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ######## subject ######## + + + + 01 Jan 2020, 00:00 + + + + + + + + ######## abstract ######## + + + + + + + + + + + + + + +`; + +exports[`MessageDetailsScreen should match the snapshot when there is an error 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Qualcosa è andato storto + + + + Non è stato possibile recuperare i dettagli del tuo messaggio. Riprova per favore + + + + + + + + + + + + + + +`; diff --git a/ts/features/pn/store/types/transformers.ts b/ts/features/pn/store/types/transformers.ts index e89ffc030b4..3b0c5269074 100644 --- a/ts/features/pn/store/types/transformers.ts +++ b/ts/features/pn/store/types/transformers.ts @@ -15,6 +15,7 @@ export const toPNMessage = ( O.chainNullableK(message => message.details), O.map(details => ({ ...details, + created_at: messageFromApi.created_at, attachments: pipe( attachmentsFromThirdPartyMessage(messageFromApi), O.toUndefined diff --git a/ts/features/pn/store/types/types.ts b/ts/features/pn/store/types/types.ts index 525cce459d3..192e7d9652a 100644 --- a/ts/features/pn/store/types/types.ts +++ b/ts/features/pn/store/types/types.ts @@ -1,7 +1,7 @@ import { IOReceivedNotification } from "../../../../../definitions/pn/IOReceivedNotification"; import { UIAttachment } from "../../../messages/types"; -export type PNMessage = IOReceivedNotification & - Readonly<{ - attachments?: ReadonlyArray; - }>; +export type PNMessage = IOReceivedNotification & { + created_at: Date; + attachments?: ReadonlyArray; +};