diff --git a/locales/en/index.yml b/locales/en/index.yml index 929270d0ba6..7c51aca2684 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -2,6 +2,8 @@ global: localization: decimalSeparator: "." + dateFormats: + dayAndMonth: "dddd D MMMM" jserror: title: Unexpected error occurred message: We have reported this to our team! Please close the app and start again! @@ -436,13 +438,16 @@ messages: one: You have 1 message other: You have {{count}} messages tab: - all: All + inbox: Inbox deadlines: Deadlines + archive: Archive contentTitle: Messages refresh: Pull down to refresh loading: Loading Messages... yesterday: yesterday cta: + archive: Archive + unarchive: Unarchive pay: Pay € {{amount}} paid: Paid € {{amount}} reminder: Reminder diff --git a/locales/it/index.yml b/locales/it/index.yml index 3f08fec3142..e61b7e1bafc 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -2,6 +2,8 @@ global: localization: decimalSeparator: "," + dateFormats: + dayAndMonth: "dddd D MMMM" jserror: title: Si è verificato un errore imprevisto message: Abbiamo segnalato questo al nostro team! Si prega di chiudere l'app e @@ -446,13 +448,16 @@ messages: one: Hai 1 messaggio other: Hai {{count}} messaggi tab: - all: Tutti + inbox: Ricevuti deadlines: Scadenze + archive: Archivio contentTitle: Messaggi refresh: Trascina in basso per aggiornare loading: Caricamento dei messaggi... yesterday: ieri cta: + archive: Archivia + unarchive: Disarchivia pay: Paga € {{amount}} paid: Pagato € {{amount}} reminder: Promemoria diff --git a/ts/components/helpers/withMessagesSelection.tsx b/ts/components/helpers/withMessagesSelection.tsx new file mode 100644 index 00000000000..f8178dd63da --- /dev/null +++ b/ts/components/helpers/withMessagesSelection.tsx @@ -0,0 +1,74 @@ +import { none, Option, some } from "fp-ts/lib/Option"; +import hoistNonReactStatics from "hoist-non-react-statics"; +import { Omit } from "italia-ts-commons/lib/types"; +import React from "react"; + +type State = { + selectedMessageIds: Option>; +}; + +export type InjectedWithMessagesSelectionProps = { + selectedMessageIds: Option>; + toggleMessageSelection: (id: string) => void; + resetSelection: () => void; +}; + +/** + * An HOC to maintain and manipulate the messages selection. + */ +export function withMessagesSelection< + P extends InjectedWithMessagesSelectionProps +>(WrappedComponent: React.ComponentType

) { + class WithMessagesSelection extends React.PureComponent< + Omit, + State + > { + constructor(props: Omit) { + super(props); + this.state = { + selectedMessageIds: none + }; + } + + public render() { + const { selectedMessageIds } = this.state; + + return ( + + ); + } + + // A function to add/remove an id from the selectedMessageIds Set. + private toggleMessageSelection = (id: string) => { + this.setState(({ selectedMessageIds }) => { + return selectedMessageIds + .map(_ => { + const newSelectedMessageIds = new Set(_); + newSelectedMessageIds.has(id) + ? newSelectedMessageIds.delete(id) + : newSelectedMessageIds.add(id); + + return { + selectedMessageIds: some(newSelectedMessageIds) + }; + }) + .getOrElse({ selectedMessageIds: some(new Set().add(id)) }); + }); + }; + + private resetSelection = () => { + this.setState({ + selectedMessageIds: none + }); + }; + } + + hoistNonReactStatics(WithMessagesSelection, WrappedComponent); + + return WithMessagesSelection; +} diff --git a/ts/components/messages/MessageAgenda.tsx b/ts/components/messages/MessageAgenda.tsx new file mode 100644 index 00000000000..ca91b60d0e8 --- /dev/null +++ b/ts/components/messages/MessageAgenda.tsx @@ -0,0 +1,94 @@ +import { format } from "date-fns"; +import { View } from "native-base"; +import React from "react"; +import { + SectionList, + SectionListData, + SectionListRenderItem, + StyleSheet +} from "react-native"; + +import I18n from "../../i18n"; +import customVariables from "../../theme/variables"; +import { MessageWithContentAndDueDatePO } from "../../types/MessageWithContentAndDueDatePO"; +import H5 from "../ui/H5"; +import MessageAgendaItem from "./MessageAgendaItem"; + +const styles = StyleSheet.create({ + sectionHeader: { + paddingHorizontal: customVariables.contentPadding, + paddingVertical: customVariables.contentPadding / 2, + backgroundColor: customVariables.brandLightGray + }, + itemSeparator: { + height: 1, + backgroundColor: customVariables.brandLightGray + } +}); + +const keyExtractor = (_: MessageWithContentAndDueDatePO) => _.id; + +const ItemSeparatorComponent = () => ; + +export type MessageAgendaSection = SectionListData< + MessageWithContentAndDueDatePO +>; + +type Props = { + // Can't use ReadonlyArray because of the SectionList section prop + // typescript definition. + // tslint:disable-next-line:readonly-array + sections: MessageAgendaSection[]; + isRefreshing: boolean; + onRefresh: () => void; + onPressItem: (id: string) => void; +}; + +/** + * A component to render messages with due_date in a agenda like form. + */ +class MessageAgenda extends React.PureComponent { + public render() { + const { sections, isRefreshing, onRefresh } = this.props; + return ( + + ); + } + + private renderSectionHeader = (info: { section: MessageAgendaSection }) => { + return ( +

+ {format( + info.section.title, + I18n.t("global.dateFormats.dayAndMonth") + ).toUpperCase()} +
+ ); + }; + + private renderItem: SectionListRenderItem< + MessageWithContentAndDueDatePO + > = info => { + const message = info.item; + return ( + + ); + }; +} + +export default MessageAgenda; diff --git a/ts/components/messages/MessageAgendaItem.tsx b/ts/components/messages/MessageAgendaItem.tsx new file mode 100644 index 00000000000..2036876f94b --- /dev/null +++ b/ts/components/messages/MessageAgendaItem.tsx @@ -0,0 +1,50 @@ +import { format } from "date-fns"; +import { Text, View } from "native-base"; +import React from "react"; +import { StyleSheet, TouchableOpacity } from "react-native"; + +import customVariables from "../../theme/variables"; +import { MessageWithContentPO } from "../../types/MessageWithContentPO"; + +const styles = StyleSheet.create({ + container: { + padding: customVariables.contentPadding, + flexDirection: "row" + }, + subject: { + flex: 1 + }, + hour: { + flex: 0 + } +}); + +type Props = { + id: string; + subject: string; + due_date: NonNullable; + onPress: (id: string) => void; +}; + +/** + * A component to render a single Agenda item. + * Extends PureComponent to avoid unnecessary re-renders. + */ +class MessageAgendaItem extends React.PureComponent { + public render() { + const { subject, due_date } = this.props; + + return ( + + + {subject} + {format(due_date, "HH:mm")} + + + ); + } + + private handlePress = () => this.props.onPress(this.props.id); +} + +export default MessageAgendaItem; diff --git a/ts/components/messages/MessageListComponent.tsx b/ts/components/messages/MessageListComponent.tsx index 860d4cfe31b..f0d84cf7dec 100644 --- a/ts/components/messages/MessageListComponent.tsx +++ b/ts/components/messages/MessageListComponent.tsx @@ -1,9 +1,9 @@ +import { Option } from "fp-ts/lib/Option"; import * as pot from "italia-ts-commons/lib/pot"; import * as React from "react"; import { FlatList, ListRenderItemInfo, RefreshControl } from "react-native"; import { MessageState } from "../../store/reducers/entities/messages/messagesById"; - import { PaymentByRptIdState } from "../../store/reducers/entities/payments"; import { ServicesByIdState } from "../../store/reducers/entities/services/servicesById"; import { MessageListItemComponent } from "./MessageListItemComponent"; @@ -14,7 +14,9 @@ type OwnProps = { paymentByRptId: PaymentByRptIdState; refreshing: boolean; onRefresh: () => void; - onListItemPress?: (messageId: string) => void; + onPressItem: (id: string) => void; + onLongPressItem: (id: string) => void; + selectedMessageIds: Option>; }; type Props = OwnProps; @@ -32,7 +34,12 @@ class MessageListComponent extends React.Component { messageState={info.item} paymentByRptId={this.props.paymentByRptId} service={service !== undefined ? service : pot.none} - onItemPress={this.props.onListItemPress} + onPress={this.props.onPressItem} + onLongPress={this.props.onLongPressItem} + isSelectionModeEnabled={this.props.selectedMessageIds.isSome()} + isSelected={this.props.selectedMessageIds + .map(_ => _.has(info.item.meta.id)) + .getOrElse(false)} /> ); }; diff --git a/ts/components/messages/MessageListItemComponent.tsx b/ts/components/messages/MessageListItemComponent.tsx index 85a72f1e4a6..890d28d001f 100644 --- a/ts/components/messages/MessageListItemComponent.tsx +++ b/ts/components/messages/MessageListItemComponent.tsx @@ -1,6 +1,6 @@ import { DateFromISOString } from "io-ts-types"; import * as pot from "italia-ts-commons/lib/pot"; -import { Text, View } from "native-base"; +import { CheckBox, Text, View } from "native-base"; import * as React from "react"; import { StyleSheet, TouchableOpacity } from "react-native"; import { Col, Grid, Row } from "react-native-easy-grid"; @@ -18,7 +18,10 @@ type OwnProps = { messageState: MessageState; paymentByRptId: GlobalState["entities"]["paymentByRptId"]; service: pot.Pot; - onItemPress?: (messageId: string) => void; + onPress: (id: string) => void; + onLongPress: (id: string) => void; + isSelectionModeEnabled: boolean; + isSelected: boolean; }; type Props = OwnProps; @@ -79,6 +82,10 @@ const styles = StyleSheet.create({ ctaBarContainer: { marginBottom: 16 + }, + + selectionCheckbox: { + left: 0 } }); @@ -112,12 +119,20 @@ export class MessageListItemComponent extends React.Component { this.props.service.kind !== nextProps.service.kind || this.props.messageState.isRead !== nextProps.messageState.isRead || (rptId !== undefined && - this.props.paymentByRptId[rptId] !== nextProps.paymentByRptId[rptId]) + this.props.paymentByRptId[rptId] !== nextProps.paymentByRptId[rptId]) || + this.props.isSelectionModeEnabled !== nextProps.isSelectionModeEnabled || + this.props.isSelected !== nextProps.isSelected ); } public render() { - const { messageState, onItemPress, paymentByRptId, service } = this.props; + const { + messageState, + paymentByRptId, + service, + isSelectionModeEnabled, + isSelected + } = this.props; const { message, meta } = messageState; @@ -152,12 +167,12 @@ export class MessageListItemComponent extends React.Component { I18n.t("messages.noContent") ); - const onItemPressHandler = onItemPress - ? () => onItemPress(meta.id) - : undefined; - return ( - + @@ -194,11 +209,19 @@ export class MessageListItemComponent extends React.Component { {subject} - + {isSelectionModeEnabled ? ( + + ) : ( + + )} @@ -216,4 +239,12 @@ export class MessageListItemComponent extends React.Component { ); } + + private handlePress = () => { + this.props.onPress(this.props.messageState.meta.id); + }; + + private handleLongPress = () => { + this.props.onLongPress(this.props.messageState.meta.id); + }; } diff --git a/ts/components/messages/MessagesArchive.tsx b/ts/components/messages/MessagesArchive.tsx new file mode 100644 index 00000000000..5d2498fac0b --- /dev/null +++ b/ts/components/messages/MessagesArchive.tsx @@ -0,0 +1,164 @@ +import * as pot from "italia-ts-commons/lib/pot"; +import { Button, Text, View } from "native-base"; +import React, { ComponentProps } from "react"; +import { StyleSheet } from "react-native"; + +import I18n from "../../i18n"; +import { lexicallyOrderedMessagesStateSelector } from "../../store/reducers/entities/messages"; +import { MessageState } from "../../store/reducers/entities/messages/messagesById"; +import { + InjectedWithMessagesSelectionProps, + withMessagesSelection +} from "../helpers/withMessagesSelection"; +import MessageListComponent from "./MessageListComponent"; + +const styles = StyleSheet.create({ + listWrapper: { + flex: 1 + }, + + buttonBar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + flexDirection: "row", + zIndex: 1, + justifyContent: "space-around", + backgroundColor: "#ddd", + padding: 10, + opacity: 0.75 + }, + buttonBarButton: { + opacity: 1 + } +}); + +type OwnProps = { + messagesState: ReturnType; + navigateToMessageDetail: (id: string) => void; + setMessagesArchivedState: ( + ids: ReadonlyArray, + archived: boolean + ) => void; +}; + +type Props = Pick< + ComponentProps, + "servicesById" | "paymentByRptId" | "onRefresh" +> & + OwnProps & + InjectedWithMessagesSelectionProps; + +type State = { + lastMessagesState: ReturnType; + filteredMessageStates: ReturnType; +}; + +/** + * Filter only the messages that are archived. + */ +const generateMessagesStateArchivedArray = ( + potMessagesState: pot.Pot, string> +): ReadonlyArray => + pot.getOrElse( + pot.map(potMessagesState, _ => + _.filter(messageState => messageState.isArchived) + ), + [] + ); + +/** + * A component to render a list of archived messages. + * It acts like a wrapper for the MessageListComponent, filtering the messages + * and adding the messages selection and archiving management. + */ +class MessagesArchive extends React.PureComponent { + /** + * Updates the filteredMessageStates only when necessary. + */ + public static getDerivedStateFromProps( + nextProps: Props, + prevState: State + ): Partial | null { + const { lastMessagesState } = prevState; + + if (lastMessagesState !== nextProps.messagesState) { + // The list was updated, we need to re-apply the filter and + // save the result in the state. + return { + filteredMessageStates: generateMessagesStateArchivedArray( + nextProps.messagesState + ), + lastMessagesState: nextProps.messagesState + }; + } + + // The state must not be changed. + return null; + } + + constructor(props: Props) { + super(props); + this.state = { + lastMessagesState: pot.none, + filteredMessageStates: [] + }; + } + + public render() { + const isLoading = pot.isLoading(this.props.messagesState); + const { selectedMessageIds, resetSelection } = this.props; + + return ( + + {selectedMessageIds.isSome() && ( + + + + + )} + + + ); + } + + private handleOnPressItem = (id: string) => { + if (this.props.selectedMessageIds.isSome()) { + // Is the selection mode is active a simple "press" must act as + // a "longPress" (select the item). + this.handleOnLongPressItem(id); + } else { + this.props.navigateToMessageDetail(id); + } + }; + + private handleOnLongPressItem = (id: string) => { + this.props.toggleMessageSelection(id); + }; + + private unarchiveMessages = () => { + this.props.resetSelection(); + this.props.setMessagesArchivedState( + this.props.selectedMessageIds.map(_ => Array.from(_)).getOrElse([]), + false + ); + }; +} + +export default withMessagesSelection(MessagesArchive); diff --git a/ts/components/messages/MessagesDeadlines.tsx b/ts/components/messages/MessagesDeadlines.tsx new file mode 100644 index 00000000000..62d0a69d188 --- /dev/null +++ b/ts/components/messages/MessagesDeadlines.tsx @@ -0,0 +1,154 @@ +import { compareAsc, startOfDay } from "date-fns"; +import { none, Option, some } from "fp-ts/lib/Option"; +import * as pot from "italia-ts-commons/lib/pot"; +import { View } from "native-base"; +import React, { ComponentProps } from "react"; +import { StyleSheet } from "react-native"; + +import { lexicallyOrderedMessagesStateSelector } from "../../store/reducers/entities/messages"; +import { MessageState } from "../../store/reducers/entities/messages/messagesById"; +import { + isMessageWithContentAndDueDatePO, + MessageWithContentAndDueDatePO +} from "../../types/MessageWithContentAndDueDatePO"; +import MessageAgenda, { MessageAgendaSection } from "./MessageAgenda"; + +const styles = StyleSheet.create({ + listWrapper: { + flex: 1 + }, + + buttonBar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + flexDirection: "row", + zIndex: 1, + justifyContent: "space-around", + backgroundColor: "#ddd", + padding: 10, + opacity: 0.75 + }, + buttonBarButton: { + opacity: 1 + } +}); + +type OwnProps = { + messagesState: ReturnType; + navigateToMessageDetail: (id: string) => void; +}; + +type Props = Pick, "onRefresh"> & OwnProps; + +/** + * Filter only the messages with a due date and group them by due_date day. + */ +const generateSections = ( + potMessagesState: pot.Pot, string> +) => + pot.getOrElse( + pot.map( + potMessagesState, + _ => + // tslint:disable-next-line:readonly-array + _.reduce( + (accumulator, messageState) => { + const message = messageState.message; + if ( + pot.isSome(message) && + isMessageWithContentAndDueDatePO(message.value) + ) { + accumulator.push(message.value); + } + + return accumulator; + }, + [] + ) + // Sort by due_date + .sort((d1, d2) => + compareAsc(d1.content.due_date, d2.content.due_date) + ) + // Now we have an array of messages sorted by due_date. + // To create groups (by due_date day) we can just iterate the array and + // - if the current message due_date day is different from the one of + // the prevMessage create a new section + // - if the current message due_date day is equal to the one of prevMessage + // add the message to the last section + .reduce<{ + lastTitle: Option; + // tslint:disable-next-line:readonly-array + sections: MessageAgendaSection[]; + }>( + (accumulator, message) => { + // As title of the section we use the ISOString rapresentation + // of the due_date day. + const title = startOfDay(message.content.due_date).toISOString(); + if ( + accumulator.lastTitle.isNone() || + title !== accumulator.lastTitle.value + ) { + // We need to create a new section + const newSection = { + title, + data: [message] + }; + return { + lastTitle: some(title), + sections: [...accumulator.sections, newSection] + }; + } else { + // We need to add the message to the last section. + // We are sure that pop will return at least one element because + // of the previous `if` step. + const prevSection = accumulator.sections.pop() as MessageAgendaSection; + const newSection = { + title, + data: [...prevSection.data, message] + }; + return { + lastTitle: some(title), + // We used pop so we need to re-add the section. + sections: [...accumulator.sections, newSection] + }; + } + }, + { + lastTitle: none, + sections: [] + } + ).sections + ), + [] + ); + +/** + * A component to show the messages with a due_date. + */ +class MessagesDeadlines extends React.PureComponent { + public render() { + const { messagesState, onRefresh } = this.props; + const isLoading = pot.isLoading(messagesState); + + const sections = generateSections(messagesState); + + return ( + + + + ); + } + + private handleOnPressItem = (id: string) => { + this.props.navigateToMessageDetail(id); + }; +} + +export default MessagesDeadlines; diff --git a/ts/components/messages/MessagesInbox.tsx b/ts/components/messages/MessagesInbox.tsx new file mode 100644 index 00000000000..aa72fb8f348 --- /dev/null +++ b/ts/components/messages/MessagesInbox.tsx @@ -0,0 +1,166 @@ +import * as pot from "italia-ts-commons/lib/pot"; +import { Button, Text, View } from "native-base"; +import React, { ComponentProps } from "react"; +import { StyleSheet } from "react-native"; + +import I18n from "../../i18n"; +import { lexicallyOrderedMessagesStateSelector } from "../../store/reducers/entities/messages"; +import { MessageState } from "../../store/reducers/entities/messages/messagesById"; +import { + InjectedWithMessagesSelectionProps, + withMessagesSelection +} from "../helpers/withMessagesSelection"; +import MessageListComponent from "./MessageListComponent"; + +const styles = StyleSheet.create({ + listWrapper: { + flex: 1 + }, + + buttonBar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + flexDirection: "row", + zIndex: 1, + justifyContent: "space-around", + backgroundColor: "#ddd", + padding: 10, + opacity: 0.75 + }, + buttonBarButton: { + opacity: 1 + } +}); + +type OwnProps = { + messagesState: ReturnType; + navigateToMessageDetail: (id: string) => void; + setMessagesArchivedState: ( + ids: ReadonlyArray, + archived: boolean + ) => void; +}; + +type Props = Pick< + ComponentProps, + "servicesById" | "paymentByRptId" | "onRefresh" +> & + OwnProps & + InjectedWithMessagesSelectionProps; + +type State = { + lastMessagesState: ReturnType; + filteredMessageStates: ReturnType< + typeof generateMessagesStateNotArchivedArray + >; +}; + +/** + * Filter only the messages that are not archived. + */ +const generateMessagesStateNotArchivedArray = ( + potMessagesState: pot.Pot, string> +): ReadonlyArray => + pot.getOrElse( + pot.map(potMessagesState, _ => + _.filter(messageState => !messageState.isArchived) + ), + [] + ); + +/** + * A component to render a list of visible (not yet archived) messages. + * It acts like a wrapper for the MessageListComponent, filtering the messages + * and adding the messages selection and archiving management. + */ +class MessagesInbox extends React.PureComponent { + /** + * Updates the filteredMessageStates only when necessary. + */ + public static getDerivedStateFromProps( + nextProps: Props, + prevState: State + ): Partial | null { + const { lastMessagesState } = prevState; + + if (lastMessagesState !== nextProps.messagesState) { + // The list was updated, we need to re-apply the filter and + // save the result in the state. + return { + filteredMessageStates: generateMessagesStateNotArchivedArray( + nextProps.messagesState + ), + lastMessagesState: nextProps.messagesState + }; + } + + // The state must not be changed. + return null; + } + + constructor(props: Props) { + super(props); + this.state = { + lastMessagesState: pot.none, + filteredMessageStates: [] + }; + } + + public render() { + const isLoading = pot.isLoading(this.props.messagesState); + const { selectedMessageIds, resetSelection } = this.props; + + return ( + + {selectedMessageIds.isSome() && ( + + + + + )} + + + ); + } + + private handleOnPressItem = (id: string) => { + if (this.props.selectedMessageIds.isSome()) { + // Is the selection mode is active a simple "press" must act as + // a "longPress" (select the item). + this.handleOnLongPressItem(id); + } else { + this.props.navigateToMessageDetail(id); + } + }; + + private handleOnLongPressItem = (id: string) => { + this.props.toggleMessageSelection(id); + }; + + private archiveMessages = () => { + this.props.resetSelection(); + this.props.setMessagesArchivedState( + this.props.selectedMessageIds.map(_ => Array.from(_)).getOrElse([]), + true + ); + }; +} + +export default withMessagesSelection(MessagesInbox); diff --git a/ts/components/ui/Markdown/handlers/internalLink.ts b/ts/components/ui/Markdown/handlers/internalLink.ts index b8a3e873b8c..40ba722dd74 100644 --- a/ts/components/ui/Markdown/handlers/internalLink.ts +++ b/ts/components/ui/Markdown/handlers/internalLink.ts @@ -11,7 +11,7 @@ import { Dispatch } from "../../../../store/actions/types"; const INTERNAL_TARGET_PREFIX = "ioit://"; const ALLOWED_ROUTE_NAMES: ReadonlyArray = [ - ROUTES.MESSAGE_LIST, + ROUTES.MESSAGES_HOME, ROUTES.PREFERENCES_HOME, ROUTES.PREFERENCES_SERVICES, ROUTES.PROFILE_MAIN, diff --git a/ts/navigation/MessagesNavigator.ts b/ts/navigation/MessagesNavigator.ts index 19002cdc8bc..f53c9fdaf0d 100644 --- a/ts/navigation/MessagesNavigator.ts +++ b/ts/navigation/MessagesNavigator.ts @@ -1,13 +1,13 @@ import { createStackNavigator } from "react-navigation"; import MessageDetailScreen from "../screens/messages/MessageDetailScreen"; -import MessageListScreen from "../screens/messages/MessageListScreen"; +import MessagesHomeScreen from "../screens/messages/MessagesHomeScreen"; import ROUTES from "./routes"; const MessagesNavigator = createStackNavigator( { - [ROUTES.MESSAGE_LIST]: { - screen: MessageListScreen + [ROUTES.MESSAGES_HOME]: { + screen: MessagesHomeScreen }, [ROUTES.MESSAGE_DETAIL]: { screen: MessageDetailScreen diff --git a/ts/navigation/routes.ts b/ts/navigation/routes.ts index 2765aef443a..e683bb7ffa3 100644 --- a/ts/navigation/routes.ts +++ b/ts/navigation/routes.ts @@ -53,7 +53,7 @@ const ROUTES = { // Messages MESSAGES_NAVIGATOR: "MESSAGES_NAVIGATOR", - MESSAGE_LIST: "MESSAGE_LIST", + MESSAGES_HOME: "MESSAGES_HOME", MESSAGE_DETAIL: "MESSAGE_DETAIL", // Profile diff --git a/ts/sagas/startup/__tests__/watchLoadMessages.test.ts b/ts/sagas/startup/__tests__/watchLoadMessages.test.ts index 9b6749b1fc9..6b960076b6b 100644 --- a/ts/sagas/startup/__tests__/watchLoadMessages.test.ts +++ b/ts/sagas/startup/__tests__/watchLoadMessages.test.ts @@ -41,6 +41,7 @@ const testMessageMeta1: MessageState = { sender_service_id: testMessageWithContent1.sender_service_id }, isRead: false, + isArchived: false, message: pot.some(toMessageWithContentPO(testMessageWithContent1)) }; @@ -63,6 +64,7 @@ const testMessageMeta2: MessageState = { sender_service_id: testMessageWithContent2.sender_service_id }, isRead: false, + isArchived: false, message: pot.some(toMessageWithContentPO(testMessageWithContent2)) }; diff --git a/ts/screens/messages/MessageListScreen.tsx b/ts/screens/messages/MessageListScreen.tsx deleted file mode 100644 index f34482dcfa1..00000000000 --- a/ts/screens/messages/MessageListScreen.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as pot from "italia-ts-commons/lib/pot"; -import { Text, View } from "native-base"; -import * as React from "react"; -import { ActivityIndicator, StyleSheet } from "react-native"; -import { NavigationScreenProps } from "react-navigation"; -import { connect } from "react-redux"; - -import MessageListComponent from "../../components/messages/MessageListComponent"; -import TopScreenComponent from "../../components/screens/TopScreenComponent"; -import I18n from "../../i18n"; -import { loadMessages } from "../../store/actions/messages"; -import { navigateToMessageDetailScreenAction } from "../../store/actions/navigation"; -import { ReduxProps } from "../../store/actions/types"; -import { lexicallyOrderedMessagesStateSelector } from "../../store/reducers/entities/messages"; -import { servicesByIdSelector } from "../../store/reducers/entities/services/servicesById"; -import { GlobalState } from "../../store/reducers/types"; -import variables from "../../theme/variables"; - -type Props = NavigationScreenProps & - ReturnType & - ReduxProps; - -const styles = StyleSheet.create({ - emptyContentContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center" - }, - - emptyContentText: { - marginBottom: 10 - } -}); - -class MessageListScreen extends React.Component { - private refreshMessageList = () => - this.props.dispatch(loadMessages.request()); - - private handleMessageListItemPress = (messageId: string) => { - this.props.dispatch(navigateToMessageDetailScreenAction({ messageId })); - }; - - public componentDidMount() { - this.refreshMessageList(); - } - - public render() { - const { potMessages, servicesById, paymentByRptId } = this.props; - - const isLoading = pot.isLoading(potMessages); - - return ( - - {pot.isSome(potMessages) && potMessages.value.length > 0 - ? this.renderFullState( - isLoading, - potMessages.value, - servicesById, - paymentByRptId - ) - : this.renderEmptyState(isLoading)} - - ); - } - - private renderEmptyState = (isLoading: boolean) => ( - - - {I18n.t("messages.counting.zero")} - - {isLoading && ( - - )} - - ); - - private renderFullState = ( - isLoading: boolean, - messages: pot.PotType, - servicesById: Props["servicesById"], - paymentByRptId: Props["paymentByRptId"] - ) => ( - - ); -} - -const mapStateToProps = (state: GlobalState) => ({ - potMessages: lexicallyOrderedMessagesStateSelector(state), - servicesById: servicesByIdSelector(state), - paymentByRptId: state.entities.paymentByRptId -}); - -export default connect(mapStateToProps)(MessageListScreen); diff --git a/ts/screens/messages/MessagesHomeScreen.tsx b/ts/screens/messages/MessagesHomeScreen.tsx new file mode 100644 index 00000000000..44a1a44771f --- /dev/null +++ b/ts/screens/messages/MessagesHomeScreen.tsx @@ -0,0 +1,121 @@ +import { Tab, Tabs } from "native-base"; +import * as React from "react"; +import { StyleSheet } from "react-native"; +import { NavigationScreenProps } from "react-navigation"; +import { connect } from "react-redux"; + +import MessagesArchive from "../../components/messages/MessagesArchive"; +import MessagesDeadlines from "../../components/messages/MessagesDeadlines"; +import MessagesInbox from "../../components/messages/MessagesInbox"; +import TopScreenComponent from "../../components/screens/TopScreenComponent"; +import I18n from "../../i18n"; +import { + loadMessages, + setMessagesArchivedState +} from "../../store/actions/messages"; +import { navigateToMessageDetailScreenAction } from "../../store/actions/navigation"; +import { Dispatch } from "../../store/actions/types"; +import { lexicallyOrderedMessagesStateSelector } from "../../store/reducers/entities/messages"; +import { paymentsByRptIdSelector } from "../../store/reducers/entities/payments"; +import { servicesByIdSelector } from "../../store/reducers/entities/services/servicesById"; +import { GlobalState } from "../../store/reducers/types"; +import customVariables from "../../theme/variables"; + +type Props = NavigationScreenProps & + ReturnType & + ReturnType; + +const styles = StyleSheet.create({ + tabContainer: { + elevation: 0, + borderBottomWidth: 1, + borderColor: customVariables.brandPrimary + }, + tabBarUnderline: { + backgroundColor: customVariables.brandPrimary + } +}); + +/** + * A screen that contains all the Tabs related to messages. + */ +class MessagesHomeScreen extends React.Component { + public componentDidMount() { + this.props.refreshMessages(); + } + + public render() { + const { + lexicallyOrderedMessagesState, + servicesById, + paymentsByRptId, + refreshMessages, + navigateToMessageDetail, + updateMessagesArchivedState + } = this.props; + + return ( + + + + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state: GlobalState) => ({ + lexicallyOrderedMessagesState: lexicallyOrderedMessagesStateSelector(state), + servicesById: servicesByIdSelector(state), + paymentsByRptId: paymentsByRptIdSelector(state) +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + refreshMessages: () => dispatch(loadMessages.request()), + navigateToMessageDetail: (messageId: string) => + dispatch(navigateToMessageDetailScreenAction({ messageId })), + updateMessagesArchivedState: ( + ids: ReadonlyArray, + archived: boolean + ) => dispatch(setMessagesArchivedState(ids, archived)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(MessagesHomeScreen); diff --git a/ts/store/actions/messages.ts b/ts/store/actions/messages.ts index c9a9360ae45..2ec0441a34d 100644 --- a/ts/store/actions/messages.ts +++ b/ts/store/actions/messages.ts @@ -44,9 +44,16 @@ export const setMessageReadState = createAction( resolve => (id: string, read: boolean) => resolve({ id, read }, { id, read }) ); +export const setMessagesArchivedState = createAction( + "MESSAGES_SET_ARCHIVED", + resolve => (ids: ReadonlyArray, archived: boolean) => + resolve({ ids, archived }) +); + export type MessagesActions = | ActionType | ActionType | ActionType | ActionType - | ActionType; + | ActionType + | ActionType; diff --git a/ts/store/reducers/entities/messages/messagesById.ts b/ts/store/reducers/entities/messages/messagesById.ts index bdd0bab7286..3ab506806bf 100644 --- a/ts/store/reducers/entities/messages/messagesById.ts +++ b/ts/store/reducers/entities/messages/messagesById.ts @@ -9,7 +9,11 @@ import { getType } from "typesafe-actions"; import { CreatedMessageWithoutContent } from "../../../../../definitions/backend/CreatedMessageWithoutContent"; import { MessageWithContentPO } from "../../../../types/MessageWithContentPO"; -import { loadMessage, setMessageReadState } from "../../../actions/messages"; +import { + loadMessage, + setMessageReadState, + setMessagesArchivedState +} from "../../../actions/messages"; import { clearCache } from "../../../actions/profile"; import { Action } from "../../../actions/types"; import { GlobalState } from "../../types"; @@ -17,6 +21,7 @@ import { GlobalState } from "../../types"; export type MessageState = { meta: CreatedMessageWithoutContent; isRead: boolean; + isArchived: boolean; message: pot.Pot; }; @@ -38,6 +43,7 @@ const reducer = ( [action.payload.id]: { meta: action.payload, isRead: false, + isArchived: false, message: pot.noneLoading } }; @@ -84,6 +90,28 @@ const reducer = ( } }; } + case getType(setMessagesArchivedState): { + const { ids, archived } = action.payload; + const updatedMessageStates = ids.reduce<{ [key: string]: MessageState }>( + (accumulator, id) => { + const prevState = state[id]; + if (prevState !== undefined) { + // tslint:disable-next-line:no-object-mutation + accumulator[id] = { + ...prevState, + isArchived: archived + }; + } + return accumulator; + }, + {} + ); + + return { + ...state, + ...updatedMessageStates + }; + } case getType(clearCache): return INITIAL_STATE; diff --git a/ts/store/reducers/entities/payments.ts b/ts/store/reducers/entities/payments.ts index 38e29700c7d..bd15ec2819d 100644 --- a/ts/store/reducers/entities/payments.ts +++ b/ts/store/reducers/entities/payments.ts @@ -10,6 +10,7 @@ import { getType } from "typesafe-actions"; import { clearCache } from "../../actions/profile"; import { Action } from "../../actions/types"; import { paymentCompletedSuccess } from "../../actions/wallet/payment"; +import { GlobalState } from "../types"; type PaidReason = Readonly< | { @@ -58,3 +59,8 @@ export const paymentByRptIdReducer = ( return state; } }; + +// Selectors + +export const paymentsByRptIdSelector = (state: GlobalState) => + state.entities.paymentByRptId; diff --git a/ts/theme/components/TabContainer.ts b/ts/theme/components/TabContainer.ts deleted file mode 100644 index f8cd9767a26..00000000000 --- a/ts/theme/components/TabContainer.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Theme } from "../types"; -export default (): Theme => { - return { - justifyContent: "flex-start", - elevation: 3, - shadowColor: "#FFF", - shadowOffset: { width: 2, height: 0 }, - shadowOpacity: 0.2, - shadowRadius: 1.2, - borderBottomWidth: 1, - borderColor: "#FFF" - }; -}; diff --git a/ts/theme/components/TabHeading.ts b/ts/theme/components/TabHeading.ts deleted file mode 100644 index 09a04c40e83..00000000000 --- a/ts/theme/components/TabHeading.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Theme } from "../types"; -import variables from "../variables"; - -export default (): Theme => { - return { - "NativeBase.Text": { - color: variables.brandDarkGray - }, - ".active": { - "NativeBase.Text": { - color: variables.brandPrimaryLight, - fontWeight: variables.h3FontWeight - } - }, - flex: 0, - width: 85, - backgroundColor: variables.brandPrimaryInverted, - borderBottomWidth: 0, - borderColor: variables.brandPrimaryInverted - }; -}; diff --git a/ts/theme/index.ts b/ts/theme/index.ts index f6047b73f17..443abde92c5 100644 --- a/ts/theme/index.ts +++ b/ts/theme/index.ts @@ -23,8 +23,6 @@ import maskedInputTheme from "./components/MaskedInput"; import messageDetailsInfoComponentTheme from "./components/MessageDetailsInfoComponent"; import preferenceItemTheme from "./components/PreferenceItem"; import screenHeaderTheme from "./components/ScreenHeader"; -import tabContainerTheme from "./components/TabContainer"; -import tabHeadingTheme from "./components/TabHeading"; import textTheme from "./components/Text"; import textWithIconTheme from "./components/TextWithIcon"; import viewTheme from "./components/View"; @@ -76,12 +74,6 @@ const theme = (): Theme => { "NativeBase.ViewNB": { ...viewTheme() }, - "NativeBase.TabHeading": { - ...tabHeadingTheme() - }, - "NativeBase.TabContainer": { - ...tabContainerTheme() - }, "UIComponent.MessageDetailsInfoComponent": { ...messageDetailsInfoComponentTheme() }, diff --git a/ts/theme/variables.ts b/ts/theme/variables.ts index b4af83b006e..cc168ca93c0 100644 --- a/ts/theme/variables.ts +++ b/ts/theme/variables.ts @@ -222,7 +222,7 @@ const customVariables = Object.assign(materialVariables, { borderRadiusBase: 4, // Tabs - tabDefaultBg: "#FFFFFF" + tabDefaultBg: "#0066CC" }); export default customVariables; diff --git a/ts/types/MessageWithContentAndDueDatePO.ts b/ts/types/MessageWithContentAndDueDatePO.ts new file mode 100644 index 00000000000..7f4ecac9c41 --- /dev/null +++ b/ts/types/MessageWithContentAndDueDatePO.ts @@ -0,0 +1,23 @@ +import * as t from "io-ts"; +import { + replaceProp1 as repP, + requiredProp1 as reqP +} from "italia-ts-commons/lib/types"; + +import { MessageContent } from "../../definitions/backend/MessageContent"; +import { MessageWithContentPO } from "./MessageWithContentPO"; + +export const MessageWithContentAndDueDatePO = repP( + MessageWithContentPO, + "content", + reqP(MessageContent, "due_date") +); + +export type MessageWithContentAndDueDatePO = t.TypeOf< + typeof MessageWithContentAndDueDatePO +>; + +export const isMessageWithContentAndDueDatePO = ( + message: MessageWithContentPO +): message is MessageWithContentAndDueDatePO => + message.content.due_date !== undefined; diff --git a/ts/types/MessageWithContentPO.ts b/ts/types/MessageWithContentPO.ts index cff5243f71c..d40782f7254 100644 --- a/ts/types/MessageWithContentPO.ts +++ b/ts/types/MessageWithContentPO.ts @@ -6,7 +6,7 @@ import { CreatedMessageWithContent } from "../../definitions/backend/CreatedMess * A plain object representation of a MessageWithContent useful to avoid problems with the redux store. * The create_at date object is transformed in a string. */ -const MessageWithContentPO = t.exact( +export const MessageWithContentPO = t.exact( t.intersection( [ t.type({