From 88fe55221e6ccd4f7f0ef689dab3373a8f123714 Mon Sep 17 00:00:00 2001 From: Filipe Santos <48060475+fc-santos@users.noreply.github.com> Date: Wed, 4 Dec 2024 08:30:20 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20de=20la=20gestion=20de=20l'?= =?UTF-8?q?=C3=A9tat=20lu/pas=20lu=20des=20notifications=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: fc-santos --- app/container-imp.ts | 4 ++ app/src/components/EventItem.tsx | 31 +++++++-- app/src/components/NotificationListItem.tsx | 67 ++++++------------- app/src/hooks/notifications.ts | 18 ++--- app/src/navigators/TabStack.tsx | 23 ++++++- .../screens/activities/NotificationsList.tsx | 64 +++++++++++++++--- app/src/store.tsx | 62 ++++++++++++++--- 7 files changed, 179 insertions(+), 90 deletions(-) diff --git a/app/container-imp.ts b/app/container-imp.ts index 07dd9bbba..841ceee01 100644 --- a/app/container-imp.ts +++ b/app/container-imp.ts @@ -53,6 +53,7 @@ import { IASEnvironment, getInitialState, QCPreferences, + ActivityState, } from './src/store' export interface AppState { @@ -217,6 +218,7 @@ export class AppContainer implements Container { let tours = initialState.tours let onboarding = initialState.onboarding let attestationAuthentificationDissmissed = initialState.attestationAuthentification + let activities = initialState.activities let { environment } = initialState.developer await Promise.all([ @@ -233,6 +235,7 @@ export class AppContainer implements Container { BCLocalStorageKeys.AttestationAuthentification, (val) => (attestationAuthentificationDissmissed = val) ), + loadState(BCLocalStorageKeys.Activities, (val) => (activities = val)), loadState(BCLocalStorageKeys.Environment, (val) => (environment = val)), ]) const state: BCState = { @@ -246,6 +249,7 @@ export class AppContainer implements Container { ...initialState.attestationAuthentification, ...attestationAuthentificationDissmissed, }, + activities, developer: { ...initialState.developer, environment, diff --git a/app/src/components/EventItem.tsx b/app/src/components/EventItem.tsx index 9473a18f5..ae9a3bc91 100644 --- a/app/src/components/EventItem.tsx +++ b/app/src/components/EventItem.tsx @@ -16,6 +16,8 @@ const iconSize = 20 interface EventItemProps { action?: GenericFn handleDelete?: () => Promise + isRead?: boolean + isHome?: boolean event: { id: string title?: string @@ -37,6 +39,8 @@ const EventItem = ({ onOpenSwipeable, activateSelection, setSelected, + isRead = true, + isHome, }: EventItemProps): React.JSX.Element => { const { t } = useTranslation() const { ColorPallet, TextTheme } = useTheme() @@ -51,8 +55,11 @@ const EventItem = ({ flex: 1, flexDirection: 'row', justifyContent: 'space-between', - backgroundColor: ColorPallet.grayscale.white, + backgroundColor: !isHome && !isRead ? ColorPallet.notification.info : ColorPallet.grayscale.white, zIndex: 9999, + ...(!isHome && { + padding: 16, + }), gap: 8, }, infoContainer: { @@ -164,8 +171,15 @@ const EventItem = ({ text1: t('Activities.NotificationsDeleted', { count: 1 }), onShow() { dispatch({ - type: BCDispatchAction.NOTIFICATIONS_TEMPORARILY_DELETED_IDS, - payload: [event.id], + type: BCDispatchAction.ACTIVITY_TEMPORARILY_DELETED_IDS, + payload: [ + { + [event.id]: { + isRead, + isTempDeleted: true, + }, + }, + ], }) }, onHide: () => { @@ -179,8 +193,15 @@ const EventItem = ({ onCancel: () => { hasCanceledRef.current = true dispatch({ - type: BCDispatchAction.NOTIFICATIONS_TEMPORARILY_DELETED_IDS, - payload: [], + type: BCDispatchAction.ACTIVITY_TEMPORARILY_DELETED_IDS, + payload: [ + { + [event.id]: { + isRead, + isTempDeleted: false, + }, + }, + ], }) }, }, diff --git a/app/src/components/NotificationListItem.tsx b/app/src/components/NotificationListItem.tsx index bd9162842..5a407e79b 100644 --- a/app/src/components/NotificationListItem.tsx +++ b/app/src/components/NotificationListItem.tsx @@ -10,7 +10,7 @@ import { W3cCredentialRecord, } from '@credo-ts/core' import { useAgent, useConnectionById } from '@credo-ts/react-hooks' -import { BifoldError, EventTypes, Screens, Stacks, useStore, useTheme } from '@hyperledger/aries-bifold-core' +import { BifoldError, EventTypes, Screens, Stacks, useStore } from '@hyperledger/aries-bifold-core' import { BasicMessageMetadata, basicMessageCustomMetadata } from '@hyperledger/aries-bifold-core/App/types/metadata' import { HomeStackParams } from '@hyperledger/aries-bifold-core/App/types/navigators' import { CustomNotification, CustomNotificationRecord } from '@hyperledger/aries-bifold-core/App/types/notification' @@ -27,6 +27,7 @@ import FleurLysImg from '../assets/img/FleurLys.svg' import MessageImg from '../assets/img/Message.svg' import ProofRequestImg from '../assets/img/ProofRequest.svg' import RevocationImg from '../assets/img/Revocation.svg' +import { BCDispatchAction, BCState } from '../store' import CustomCheckBox from './CustomCheckBox' import EventItem from './EventItem' @@ -55,6 +56,7 @@ interface NotificationListItemProps { selected?: boolean setSelected?: ({ id, deleteAction }: { id: string; deleteAction?: () => Promise }) => void activateSelection?: boolean + isHome?: boolean } type DisplayDetails = { @@ -79,11 +81,12 @@ const NotificationListItem: React.FC = ({ selected, setSelected, activateSelection, + isHome = true, }) => { const navigation = useNavigation>() - const [store, dispatch] = useStore() + const [store, dispatch] = useStore() + const storeNofication = store.activities[notification.id] const { t } = useTranslation() - const { ColorPallet, TextTheme } = useTheme() const { agent } = useAgent() const isNotCustomNotification = notification instanceof BasicMessageRecord || @@ -98,55 +101,10 @@ const NotificationListItem: React.FC = ({ }) const styles = StyleSheet.create({ - container: { - flex: 1, - flexDirection: 'row', - justifyContent: 'space-between', - backgroundColor: ColorPallet.grayscale.white, - zIndex: 9999, - gap: 8, - }, - infoContainer: { - flex: 2, - }, - arrowContainer: { - justifyContent: 'center', - }, - headerText: { - ...TextTheme.labelTitle, - flexGrow: 1, - flex: 1, - }, - bodyText: { - ...TextTheme.labelSubtitle, - marginVertical: 8, - }, - bodyEventTime: { - ...TextTheme.labelSubtitle, - color: ColorPallet.grayscale.mediumGrey, - fontSize: 12, - }, icon: { width: 24, height: 24, }, - rightAction: { - padding: 8, - backgroundColor: ColorPallet.semantic.error, - minWidth: 120, - justifyContent: 'center', - flex: 1, - marginVertical: 'auto', - alignItems: 'center', - }, - rightActionIcon: { - color: ColorPallet.brand.secondary, - }, - rightActionText: { - color: ColorPallet.brand.secondary, - fontSize: 14, - fontWeight: '600', - }, }) const handleSwipeClose = () => { @@ -303,6 +261,17 @@ const NotificationListItem: React.FC = ({ | W3cCredentialRecord, notificationType: NotificationTypeEnum ) => { + dispatch({ + type: BCDispatchAction.NOTIFICATIONS_UPDATED, + payload: [ + { + [notification.id]: { + isRead: true, + isTempDeleted: false, + }, + }, + ], + }) switch (notificationType) { case NotificationTypeEnum.BasicMessage: navigation.getParent()?.navigate(Stacks.ContactStack, { @@ -374,6 +343,8 @@ const NotificationListItem: React.FC = ({ return ( -export const useNotifications = ({ openIDUri, isHome = true }: NotificationsInputProps): NotificationReturnType => { +export const useNotifications = ({ isHome = true }: NotificationsInputProps): NotificationReturnType => { const { records: basicMessages } = useBasicMessages() - const [notifications, setNotifications] = useState([]) + const [notifications, setNotifications] = useState([]) const credsReceived = useCredentialByState(CredentialState.CredentialReceived) const credsDone = useCredentialByState(CredentialState.Done) const proofsDone = useProofByState([ProofState.Done, ProofState.PresentationReceived]) const offers = useCredentialByState(CredentialState.OfferReceived) const proofsRequested = useProofByState(ProofState.RequestReceived) - const openIDCredReceived = useOpenID({ openIDUri }) const { agent } = useAgent() const [store] = useStore() @@ -139,16 +136,11 @@ export const useNotifications = ({ openIDUri, isHome = true }: NotificationsInpu ) }) - const openIDCreds: Array = [] - if (openIDCredReceived) { - openIDCreds.push(openIDCredReceived) - } - - let notif = [...messagesToShow, ...custom, ...receivedOffers, ...proofs, ...revoked, ...openIDCreds].sort( + let notif = [...messagesToShow, ...custom, ...receivedOffers, ...proofs, ...revoked].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) - notif = notif.filter((n) => !store.notificationsTempDeletedIds.includes((n as NotificationType).id)) + notif = notif.filter((n) => !store.activities[n.id]?.isTempDeleted) setNotifications(isHome ? (notif.splice(0, 5) as never[]) : (notif as never[])) }, [ @@ -159,7 +151,7 @@ export const useNotifications = ({ openIDUri, isHome = true }: NotificationsInpu nonAttestationProofs, store.attestationAuthentification.isDismissed, store.attestationAuthentification.isSeenOnHome, - store.notificationsTempDeletedIds, + store.activities, ]) useEffect(() => { diff --git a/app/src/navigators/TabStack.tsx b/app/src/navigators/TabStack.tsx index 2184538c2..cf6cd9a11 100644 --- a/app/src/navigators/TabStack.tsx +++ b/app/src/navigators/TabStack.tsx @@ -27,7 +27,8 @@ import AtestationTabIcon from '../assets/img/icons/atestation.svg' import HomeTabIcon from '../assets/img/icons/home.svg' import NotificationTabIcon from '../assets/img/icons/notification.svg' import PlusTabIcon from '../assets/img/icons/plus.svg' -import { NotificationReturnType, NotificationsInputProps } from '../hooks/notifications' +import { NotificationReturnType, NotificationsInputProps, NotificationType } from '../hooks/notifications' +import { BCDispatchAction, BCState, ActivityState } from '../store' import { notificationsSeenOnHome } from '../utils/notificationsSeenOnHome' import ActivitiesStack from './ActivitiesStack' @@ -37,7 +38,7 @@ import { TabStackParams, TabStacks } from './navigators' const TabStack: React.FC = () => { const { fontScale } = useWindowDimensions() const { agent } = useAgent() - const [store, dispatch] = useStore() + const [store, dispatch] = useStore() const navigation = useNavigation>() const [{ useNotifications }, { enableImplicitInvitations, enableReuseConnections }, logger] = useServices([ @@ -148,6 +149,24 @@ const TabStack: React.FC = () => { } }, [agent, notifications]) + useEffect(() => { + const notificationsToAdd = {} as ActivityState + for (const n of notifications) { + if (!store.activities[(n as NotificationType).id]) { + notificationsToAdd[(n as NotificationType).id] = { + isRead: false, + isTempDeleted: false, + } + } + } + if (Object.keys(notificationsToAdd).length > 0) { + dispatch({ + type: BCDispatchAction.NOTIFICATIONS_UPDATED, + payload: [notificationsToAdd], + }) + } + }, [notifications]) + const TabBarIcon = (props: { focused: boolean color: string diff --git a/app/src/screens/activities/NotificationsList.tsx b/app/src/screens/activities/NotificationsList.tsx index cacd098ea..39b624ea3 100644 --- a/app/src/screens/activities/NotificationsList.tsx +++ b/app/src/screens/activities/NotificationsList.tsx @@ -4,18 +4,19 @@ import moment from 'moment' import React, { useCallback, useEffect, useRef, useState } from 'react' import { TFunction, useTranslation } from 'react-i18next' import { View, StyleSheet, SectionList, Text } from 'react-native' -import { ToastShowParams } from 'react-native-toast-message' +import Toast, { ToastShowParams } from 'react-native-toast-message' import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons' import NotificationListItem, { NotificationTypeEnum } from '../../components/NotificationListItem' import { NotificationReturnType, NotificationsInputProps, NotificationType } from '../../hooks/notifications' import { useToast } from '../../hooks/toast' import { ActivitiesStackParams } from '../../navigators/navigators' -import { BCDispatchAction, BCState } from '../../store' +import { BCDispatchAction, BCState, ActivityState } from '../../store' import { TabTheme } from '../../theme' export type SelectedNotificationType = { id: string; deleteAction?: () => Promise } +const isHome = false const iconSize = 24 // Function to group notifications by date const groupNotificationsByDate = (notifications: NotificationReturnType, t: TFunction<'translation', undefined>) => { @@ -67,13 +68,19 @@ const NotificationsList: React.FC<{ navigation: StackNavigationProp }> = ({ openSwipeableId, handleOpenSwipeable, navigation }) => { const [{ customNotificationConfig: customNotification, useNotifications }] = useServices([TOKENS.NOTIFICATIONS]) - const notifications = useNotifications({ isHome: false } as NotificationsInputProps) - const [, dispatch] = useStore() + const notifications = useNotifications({ isHome } as NotificationsInputProps) + const [store, dispatch] = useStore() const [toastEnabled, setToastEnabled] = useState(false) const [toastOptions, setToastOptions] = useState({}) useToast({ enabled: toastEnabled, options: toastOptions }) + useEffect(() => { + return () => { + if (toastEnabled) Toast.hide() + } + }, [toastEnabled]) + const [setions, setSections] = useState([]) const { t } = useTranslation() const { ColorPallet, TextTheme } = useTheme() @@ -93,37 +100,65 @@ const NotificationsList: React.FC<{ } }, [selectedNotification]) + const removeTempNot = () => { + const ids = (selectedNotification ?? []).map((s) => s.id) + const payload = {} as ActivityState + for (const key of ids) { + payload[key] = { + ...store.activities[key], + } + } + dispatch({ + type: BCDispatchAction.ACTIVITY_MULTIPLE_DELETED, + payload: [payload], + }) + } + const deleteMultipleNotifications = async () => { for await (const notif of selectedNotification ?? []) { await notif.deleteAction?.() } + removeTempNot() } const handleMultipleDelete = () => { const selected = selectedNotification ?? [] if (selected.length > 0) { + const ids = [...selected.map((s) => s.id)] + const payload = {} as ActivityState setToastOptions({ type: ToastType.Info, text1: t('Activities.NotificationsDeleted', { count: selected.length }), onShow: () => { + for (const key of ids) { + payload[key] = { + ...store.activities[key], + isTempDeleted: true, + } + } dispatch({ - type: BCDispatchAction.NOTIFICATIONS_TEMPORARILY_DELETED_IDS, - payload: [...selected.map((s) => s.id)], + type: BCDispatchAction.ACTIVITY_TEMPORARILY_DELETED_IDS, + payload: [payload], }) }, onHide: () => { if (!hasCanceledRef.current) { deleteMultipleNotifications() } - hasCanceledRef.current = false setToastEnabled(false) }, props: { onCancel: () => { hasCanceledRef.current = true + for (const key of ids) { + payload[key] = { + ...store.activities[key], + isTempDeleted: false, + } + } dispatch({ - type: BCDispatchAction.NOTIFICATIONS_TEMPORARILY_DELETED_IDS, - payload: [], + type: BCDispatchAction.ACTIVITY_TEMPORARILY_DELETED_IDS, + payload: [payload], }) }, }, @@ -138,13 +173,14 @@ const NotificationsList: React.FC<{ container: { flex: 1, zIndex: 1, + marginBottom: 16, }, sectionList: { flex: 1, - paddingHorizontal: 16, }, separator: { borderBottomWidth: 1, + marginHorizontal: 16, borderBottomColor: ColorPallet.brand.secondary, }, bodyText: { @@ -158,10 +194,12 @@ const NotificationsList: React.FC<{ sectionSeparator: { height: 1, backgroundColor: ColorPallet.brand.secondary, + paddingHorizontal: 16, marginTop: 4, }, sectionHeaderContainer: { marginBottom: 12, + paddingHorizontal: 16, backgroundColor: ColorPallet.brand.primaryBackground, }, notificationContainer: { @@ -199,6 +237,7 @@ const NotificationsList: React.FC<{ onOpenSwipeable={handleOpenSwipeable} notificationType={NotificationTypeEnum.BasicMessage} notification={item} + isHome={isHome} activateSelection={selectedNotification != null} selected={ (selectedNotification?.filter((selectedNotification) => selectedNotification.id === item.id)?.length ?? @@ -229,6 +268,7 @@ const NotificationsList: React.FC<{ onOpenSwipeable={handleOpenSwipeable} notificationType={notificationType} notification={item} + isHome={isHome} activateSelection={selectedNotification != null} selected={ (selectedNotification?.filter((selectedNotification) => selectedNotification.id === item.id)?.length ?? @@ -255,6 +295,7 @@ const NotificationsList: React.FC<{ onOpenSwipeable={handleOpenSwipeable} notificationType={NotificationTypeEnum.Custom} notification={item} + isHome={isHome} customNotification={customNotification} activateSelection={selectedNotification != null} selected={ @@ -282,6 +323,7 @@ const NotificationsList: React.FC<{ onOpenSwipeable={handleOpenSwipeable} notificationType={NotificationTypeEnum.ProofRequest} notification={item} + isHome={isHome} activateSelection={selectedNotification != null} selected={ (selectedNotification?.filter((selectedNotification) => selectedNotification.id === item.id)?.length ?? @@ -325,7 +367,7 @@ const NotificationsList: React.FC<{ ItemSeparatorComponent={() => } renderSectionHeader={renderSectionHeader} ListFooterComponent={ - + {t('Activities.FooterNothingElse')} } diff --git a/app/src/store.tsx b/app/src/store.tsx index 3c38fa84b..7d114aeb2 100644 --- a/app/src/store.tsx +++ b/app/src/store.tsx @@ -29,6 +29,13 @@ export interface AttestationAuthentification { isSeenOnHome: boolean } +export interface ActivityState { + [id: string]: { + isRead?: boolean + isTempDeleted: boolean + } +} + export interface QCPreferences extends Preferences { useForcedAppUpdate?: boolean } @@ -37,7 +44,7 @@ export interface BCState extends BifoldState { developer: Developer attestationAuthentification: AttestationAuthentification preferences: QCPreferences - notificationsTempDeletedIds: string[] + activities: ActivityState } enum DeveloperDispatchAction { @@ -49,8 +56,10 @@ enum AttestationAuthentificationDispatchAction { ATTESTATION_AUTHENTIFICATION_SEEN_ON_HOME = 'attestationAuthentification/attestationAuthentificationSeenOnHome', } -enum NotificationsTemporarilyDeletedDispatchAction { - NOTIFICATIONS_TEMPORARILY_DELETED_IDS = 'notifications/temporarilyDeletedIds', +enum ActivityDispatchAction { + NOTIFICATIONS_UPDATED = 'activity/notificationsUpdated', + ACTIVITY_MULTIPLE_DELETED = 'activity/activitiesMultipleDeleted', + ACTIVITY_TEMPORARILY_DELETED_IDS = 'activity/activitiesTemporarilyDeletedIds', } export enum PreferencesQCDispatchAction { @@ -61,13 +70,13 @@ export type BCDispatchAction = | DeveloperDispatchAction | AttestationAuthentificationDispatchAction | PreferencesQCDispatchAction - | NotificationsTemporarilyDeletedDispatchAction + | ActivityDispatchAction export const BCDispatchAction = { ...DeveloperDispatchAction, ...AttestationAuthentificationDispatchAction, ...PreferencesQCDispatchAction, - ...NotificationsTemporarilyDeletedDispatchAction, + ...ActivityDispatchAction, } export const iasEnvironments: Array = [ @@ -94,7 +103,7 @@ export enum BCLocalStorageKeys { AttestationAuthentification = 'AttestationAuthentification', Environment = 'Environment', GenesisTransactions = 'GenesisTransactions', - NotificationsTemporarilyDeleted = 'NotificationsTemporarilyDeleted', + Activities = 'Activities', } const getInitialAttestationAuthentification = async (): Promise => { @@ -116,8 +125,21 @@ const getInitialAttestationAuthentification = async (): Promise => { + const activitiesString = await AsyncStorage.getItem(BCLocalStorageKeys.Activities) + let activities: ActivityState = {} + if (activitiesString) { + activities = JSON.parse(activitiesString) as ActivityState + } else { + AsyncStorage.setItem(BCLocalStorageKeys.Activities, JSON.stringify(activities)) + } + + return activities +} + export const getInitialState = async (): Promise => { const attestationAuthentification = await getInitialAttestationAuthentification() + const activities = await getInitialActivitiesState() return { ...defaultState, developer: developerState, @@ -126,7 +148,7 @@ export const getInitialState = async (): Promise => { ...defaultState.preferences, useForcedAppUpdate: false, }, - notificationsTempDeletedIds: [], + activities, } } @@ -162,10 +184,28 @@ const bcReducer = (state: BCState, action: ReducerAction): BCS AsyncStorage.setItem(BCLocalStorageKeys.AttestationAuthentification, JSON.stringify(attestationAuthentification)) return newState } - case NotificationsTemporarilyDeletedDispatchAction.NOTIFICATIONS_TEMPORARILY_DELETED_IDS: { - const ids: string[] = action?.payload || [] - const newState = { ...state, notificationsTempDeletedIds: ids } - AsyncStorage.setItem(BCLocalStorageKeys.NotificationsTemporarilyDeleted, JSON.stringify(ids)) + case ActivityDispatchAction.ACTIVITY_TEMPORARILY_DELETED_IDS: { + const activities: ActivityState = (action?.payload || []).pop() + const newState = { ...state, activities: { ...state.activities, ...activities } } + AsyncStorage.setItem(BCLocalStorageKeys.Activities, JSON.stringify(newState.activities)) + return newState + } + case ActivityDispatchAction.NOTIFICATIONS_UPDATED: { + const activities: ActivityState = (action?.payload || []).pop() + const newState = { ...state, activities: { ...state.activities, ...activities } } + AsyncStorage.setItem(BCLocalStorageKeys.Activities, JSON.stringify(newState.activities)) + return newState + } + case ActivityDispatchAction.ACTIVITY_MULTIPLE_DELETED: { + const activities: ActivityState = (action?.payload || []).pop() + const activitiesUpdated = state.activities + Object.keys(activities).forEach((key) => { + if (activitiesUpdated[key]) { + delete activitiesUpdated[key] + } + }) + const newState = { ...state, activities: activitiesUpdated } + AsyncStorage.setItem(BCLocalStorageKeys.Activities, JSON.stringify(newState.activities)) return newState } case PreferencesQCDispatchAction.USE_APP_FORCED_UPDATE: {