diff --git a/src/components/loadingWindow.tsx b/src/components/loadingWindow.tsx index 3b7d3d5..9057f06 100644 --- a/src/components/loadingWindow.tsx +++ b/src/components/loadingWindow.tsx @@ -24,6 +24,9 @@ type LoadingWindowProps = { * @param {Object} props.hideAvatar - Flag for hide avatar placeholder circle from loading window card. * @param {Object} props.hideDelete - Flag for hide delete icon placeholder square from loading window card. */ + +const loadingArray = Array(10).fill(0).map((_, i) => i + 1); + const LoadingWindow = (props: LoadingWindowProps): ReactElement => { const { styles, customLoader, hideAvatar, hideDelete } = props; @@ -103,7 +106,11 @@ const LoadingWindow = (props: LoadingWindowProps): ReactElement => { return ( {customLoader || ( - + )} ); diff --git a/src/components/sirenInbox.tsx b/src/components/sirenInbox.tsx index ecea810..f59a0bd 100644 --- a/src/components/sirenInbox.tsx +++ b/src/components/sirenInbox.tsx @@ -1,19 +1,20 @@ import React, { type ReactElement, useEffect, useMemo, useState, useRef } from 'react'; import { FlatList, StyleSheet, View } from 'react-native'; - -import PubSub from 'pubsub-js'; import type { Siren } from '@sirenapp/js-sdk'; import type { NotificationDataType, SirenErrorType } from '@sirenapp/js-sdk/dist/esm/types'; +import PubSub from 'pubsub-js'; +import { useSirenContext } from './sirenProvider'; +import type { SirenInboxProps } from '../types'; +import { CommonUtils, Constants, useSiren } from '../utils'; +import { FilterTypes } from '../utils/constants'; import Card from './card'; import EmptyWindow from './emptyWindow'; import ErrorWindow from './errorWindow'; import Header from './header'; import LoadingWindow from './loadingWindow'; import Spinner from './spinner'; -import { useSirenContext } from './sirenProvider'; -import type { SirenInboxProps } from '../types'; -import { CommonUtils, Constants, useSiren } from '../utils'; +import Tabs from './tab'; const { DEFAULT_WINDOW_TITLE, @@ -31,6 +32,7 @@ type fetchProps = { size: number; end?: string; start?: string; + isRead?: boolean; }; type NotificationFetchParams = { @@ -38,6 +40,7 @@ type NotificationFetchParams = { end?: string; start?: string; sort?: 'createdAt' | 'updatedAt'; + isRead?: boolean; }; /** @@ -78,7 +81,7 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { hideAvatar: false, disableAutoMarkAsRead: false, hideDelete: false, - hideMediaThumbnail: false, + hideMediaThumbnail: false }, listEmptyComponent = null, headerProps = {}, @@ -88,7 +91,15 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { customCard = null, onCardClick = () => null, onError = () => {}, - itemsPerFetch = 20 + itemsPerFetch = 20, + hideTab = false, + tabProps = { + tabs: [ + { key: FilterTypes.ALL, title: FilterTypes.ALL }, + { key: FilterTypes.UNREAD, title: FilterTypes.UNREAD } + ], + activeTab: 0 + } } = props; const { @@ -113,6 +124,8 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { const [isLoading, setIsLoading] = useState(true); const [endReached, setEndReached] = useState(false); const [isError, setIsError] = useState(false); + const [filterType, setFilterType] = useState(tabProps.tabs[tabProps.activeTab].key); + const [activeTab, setActiveTab] = useState(tabProps.activeTab); const [eventListenerData, setEventListenerData] = useState<{ id?: string; action: string; @@ -128,17 +141,23 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { return cleanUp(); }, []); + useEffect(() => { + const updatedActiveIndex = tabProps.tabs.findIndex((tab) => tab.key === filterType); + + setActiveTab(updatedActiveIndex); + }, [filterType]); + useEffect(() => { // Initialize Siren SDK and start polling notifications if (verificationStatus === VerificationStatus.SUCCESS && siren) { initialize(); - } else if(verificationStatus === VerificationStatus.FAILED) { + } else if (verificationStatus === VerificationStatus.FAILED) { setIsError(true); setIsLoading(false); setNotifications([]); if (onError) onError(errorMap.INVALID_CREDENTIALS); } - }, [siren, verificationStatus]); + }, [siren, verificationStatus, filterType]); useEffect(() => { if (eventListenerData) { @@ -186,6 +205,8 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { // Initialize Siren SDK and fetch notifications const initialize = async (): Promise => { if (siren) { + setNotifications([]); + setEndReached(false); siren?.stopRealTimeFetch(EventType.NOTIFICATION); const allNotifications = await fetchNotifications(siren, true); const notificationParams: fetchProps = { size: notificationsPerPage }; @@ -193,8 +214,13 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { if (isNonEmptyArray(allNotifications)) notificationParams.start = allNotifications[0].createdAt; + if (filterType === FilterTypes.UNREAD) notificationParams.isRead = false; + if (verificationStatus === VerificationStatus.SUCCESS) - siren?.startRealTimeFetch({eventType: EventType.NOTIFICATION, params: notificationParams}); + siren?.startRealTimeFetch({ + eventType: EventType.NOTIFICATION, + params: notificationParams + }); } }; @@ -204,6 +230,8 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { sort: 'createdAt' }; + if (filterType === FilterTypes.UNREAD) notificationParams.isRead = false; + if (attachEndDate) notificationParams.end = notifications[notifications.length - 1].createdAt; return notificationParams; @@ -267,6 +295,13 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { [theme, darkMode, customStyles] ); + const onChangeTab = (key: string) => { + setIsLoading(true); + setFilterType(key); + setNotifications([]); + setEndReached(false); + }; + // Refresh notifications const onRefresh = async (): Promise => { if (siren) @@ -283,8 +318,13 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { if (isNonEmptyArray(allNotifications)) notificationParams.start = allNotifications[0].createdAt; + if (filterType === FilterTypes.UNREAD) notificationParams.isRead = false; + if (verificationStatus === VerificationStatus.SUCCESS) - siren?.startRealTimeFetch({eventType: EventType.NOTIFICATION, params:notificationParams}); + siren?.startRealTimeFetch({ + eventType: EventType.NOTIFICATION, + params: notificationParams + }); } catch (err) { setIsLoading(false); setIsError(true); @@ -298,31 +338,6 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { if (fetchMore) fetchNotifications(siren, false); }; - // Render empty window, error window, or custom empty component - const renderListEmpty = (): JSX.Element | null => { - if (!isLoading) { - if (isError) - return ( - - ); - - return ( - - {listEmptyComponent || } - - ); - } - - return ( - - ); - }; - const onDelete = async (id: string, shouldUpdateList: boolean): Promise => { let isSuccess = false; @@ -330,7 +345,7 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { disableCardDelete.current = true; const response = await deleteById(id, shouldUpdateList); - + if (response?.data) isSuccess = true; processError(response?.error); disableCardDelete.current = false; @@ -404,12 +419,60 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { ); }; + const getScreens = (): JSX.Element[] => { + return tabProps.tabs.map((tab, index) => { + return renderList(activeTab === index, tab.key); + }); + }; + + const renderTabs = (): JSX.Element | null => { + return ( + + ); + }; + const keyExtractor = (item: NotificationDataType) => item.id; - const renderList = (): JSX.Element => { + const renderEmptyState = (isActiveTab: boolean): JSX.Element => { + if (isLoading || !isActiveTab) + return ( + + + + ); + + if (isError) + return ( + + ); + + return ( + + + {listEmptyComponent || } + + + ); + }; + + const renderList = (isActiveTab: boolean, key: string): JSX.Element => { + if (!isNonEmptyArray(notifications) || !isActiveTab) return renderEmptyState(isActiveTab); + return ( { removeClippedSubviews maxToRenderPerBatch={20} windowSize={3} + showsVerticalScrollIndicator={false} accessibilityLabel='siren-notification-list' /> ); @@ -428,7 +492,7 @@ const SirenInbox = (props: SirenInboxProps): ReactElement => { return ( {renderHeader()} - {isNonEmptyArray(notifications) ? renderList() : renderListEmpty()} + {!hideTab && renderTabs()} {customFooter || null} ); @@ -438,6 +502,13 @@ const style = StyleSheet.create({ container: { minWidth: 300, flex: 1 + }, + swipeContainer: { + flex: 1 + }, + tabContainer: { + width: '100%', + height: '100%' } }); diff --git a/src/components/tab.tsx b/src/components/tab.tsx new file mode 100644 index 0000000..2280215 --- /dev/null +++ b/src/components/tab.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useRef, useState, type ReactElement } from 'react'; +import { + Animated, + type LayoutChangeEvent, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + Dimensions, + type NativeSyntheticEvent, + type NativeScrollEvent +} from 'react-native'; + +import type { StyleProps } from '../types'; + +const { width } = Dimensions.get('window'); + +/** + * + * @component + * @example + * console.log(index, key)} + * /> + * + * @param {Object} props - The properties passed to the Tab component. + * @param {number} props.activeIndex - activeIndex control the tab selection. + * @param {Object} props.styles - Custom styles to apply to the header component. + * @param {Array} props.tabs - List of tab items to be rendered. + * @param {Function} props.onChangeTabItem - Callback function to handle tab item change. + * @param {Array} props.screens - List of screens to be rendered. + */ + +type TabProps = { + activeIndex?: number; + tabs: Array<{ key: string; title: string }>; + styles: Partial; + onChangeTabItem?: (key: string) => void; + screens?: ReactElement[]; +}; + +const defaultScreensColors = ['#b91919', '#2E8B57', '#0047AB']; +const defaultWidth = '100%'; + +const defaultScreens = defaultScreensColors.map((color, index) => ( + +)); + +const Tabs = (props: TabProps): ReactElement => { + const { + tabs, + activeIndex = 0, + styles, + onChangeTabItem = () => null, + screens = defaultScreens + } = props; + const translateX = useRef(new Animated.Value(0)).current; + const scaleWidth = useRef(new Animated.Value(0)).current; + const scrollViewRef = useRef(null); + + const [activeTabIndex, setActiveTabIndex] = useState(activeIndex); + const [tabTitleWidths, setTabTitleWidths] = useState([]); + + useEffect(() => { + if (tabTitleWidths.length > 0) + moveIndicator(); + }, [tabTitleWidths, activeTabIndex]); + + useEffect(() => { + setActiveTabIndex(activeIndex); + }, [activeIndex]); + + useEffect(() => { + if (scrollViewRef.current) + scrollViewRef.current.scrollTo({ x: activeTabIndex * width, animated: true }); + }, [activeTabIndex]); + + const handleScroll = (event: NativeSyntheticEvent) => { + const contentOffsetX = event.nativeEvent.contentOffset.x; + const index = Math.round(contentOffsetX / width); + + if (index !== activeTabIndex) onChangeTab(index); + }; + + const onChangeTab = (index: number) => { + setActiveTabIndex(index); + onChangeTabItem(tabs[index].key); + }; + + const getIndicatorPosition = () => { + let position = 0; + + for (let i = 0; i < activeTabIndex; i++) position += tabTitleWidths[i]; + position += (tabTitleWidths[activeTabIndex] || 0) / 2; + + return position; + }; + + const moveIndicator = () => { + const position = getIndicatorPosition(); + const width = tabTitleWidths[activeTabIndex] || 0; + + Animated.parallel([ + Animated.timing(translateX, { + toValue: position, + duration: 200, + useNativeDriver: true + }), + Animated.timing(scaleWidth, { + toValue: width * 0.85, + duration: 200, + useNativeDriver: true + }) + ]).start(); + }; + + const onTabTitleLayout = (index: number, event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout; + + const updatedWidths = [...tabTitleWidths]; + + updatedWidths[index] = width; + setTabTitleWidths(updatedWidths); + }; + + const renderTabHeader = () => { + return ( + + + {tabs.map((tab, index) => ( + onChangeTab(index)} + onLayout={(event) => onTabTitleLayout(index, event)} + > + + {tab.title} + + + ))} + + + + ); + }; + + const renderTabContainer = () => { + return ( + + + {screens.map((screen, index) => ( + + {screen} + + ))} + + + ); + }; + + return ( + + {renderTabHeader()} + {renderTabContainer()} + + ); +}; +const style = StyleSheet.create({ + container: { + flex: 1, + height: '100%', + width: '100%' + }, + tabContainer: { + width: '100%', + flexDirection: 'row', + height: 46, + paddingHorizontal: 10, + borderBottomWidth: 0.6 + }, + tab: { + justifyContent: 'center', + alignItems: 'center', + minWidth: 30, + }, + tabText: { + color: '#000', + fontWeight: '600', + paddingHorizontal: 14, + fontSize: 14 + }, + activeIndicator: { + width: 1, + height: 3, + backgroundColor: '#000', + position: 'absolute', + bottom: 0 + }, + tabScreen: { + width, + overflow: 'hidden' + } +}); + +export default Tabs; diff --git a/src/types.ts b/src/types.ts index 15ae4c9..180ff5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,8 @@ export type SirenInboxProps = { customLoader?: JSX.Element; customErrorWindow?: JSX.Element; itemsPerFetch?: number; + hideTab?: boolean; + tabProps?: TabProps; customCard?: (notification: NotificationDataType) => JSX.Element; onCardClick?: (notification: NotificationDataType) => void; onError?: (error: SirenErrorType) => void; @@ -143,6 +145,14 @@ export type ThemeProps = { descriptionColor?: string; dateColor?: string; }; + tabs?: { + containerBackgroundColor?: string; + activeTabBackgroundColor?: string; + inactiveTabBackgroundColor?: string; + activeTabTextColor?: string; + inactiveTabTextColor?: string; + indicatorColor?: string; + }; }; export type CustomStyleProps = { @@ -170,7 +180,7 @@ export type CustomStyleProps = { titleFontWeight?: TextStyle['fontWeight']; titleSize?: number; subtitleFontWeight?: TextStyle['fontWeight']; - subtitleSize?: number + subtitleSize?: number; descriptionFontWeight?: TextStyle['fontWeight']; descriptionSize?: number; dateSize?: number; @@ -181,14 +191,23 @@ export type CustomStyleProps = { top?: number; right?: number; }; - deleteIcon?:{ - size?: number + deleteIcon?: { + size?: number; }; - timerIcon?:{ - size?: number + timerIcon?: { + size?: number; }; - clearAllIcon?:{ - size?: number + clearAllIcon?: { + size?: number; + }; + tabs?: { + containerHeight?: number; + activeTabTextSize?: number; + inactiveTabTextSize?: number; + activeTabTextWeight?: TextStyle['fontWeight']; + inactiveTabTextWeight?: TextStyle['fontWeight']; + indicatorHeight?: number; + tabPadding?: number; }; }; @@ -248,4 +267,16 @@ export type StyleProps = { highlighted: ViewStyle; backIcon: ViewStyle; mediaContainer: ViewStyle; + tabContainer: ViewStyle; + tab: ViewStyle; + activeTab: ViewStyle; + inActiveTab: ViewStyle; + activeTabText: TextStyle | object; + inActiveTabText: TextStyle | object; + activeIndicator: ViewStyle; +}; + +export type TabProps = { + tabs: Array<{ key: string; title: string }>; + activeTab: number; }; diff --git a/src/utils/commonUtils.ts b/src/utils/commonUtils.ts index 4ba25a7..721e2a9 100644 --- a/src/utils/commonUtils.ts +++ b/src/utils/commonUtils.ts @@ -285,4 +285,35 @@ export const applyTheme = ( mediaContainer: { backgroundColor: DefaultTheme[mode].notificationCard.mediaContainerBackground }, + tabContainer: { + backgroundColor: + theme.tabs?.containerBackgroundColor || DefaultTheme[mode].tabs.containerBackgroundColor, + borderBottomColor: theme.colors?.borderColor || DefaultTheme[mode].colors.borderColor, + height: customStyles.tabs?.containerHeight || defaultStyles.tabs.containerHeight + }, + tab: { + paddingHorizontal: customStyles.tabs?.tabPadding || defaultStyles.tabs.tabPadding, + }, + activeTab: { + backgroundColor: + theme.tabs?.activeTabBackgroundColor || DefaultTheme[mode].tabs.activeTabBackgroundColor + }, + inActiveTab: { + backgroundColor: + theme.tabs?.inactiveTabBackgroundColor || DefaultTheme[mode].tabs.inactiveTabBackgroundColor + }, + activeTabText: { + color: theme.tabs?.activeTabTextColor || DefaultTheme[mode].tabs.activeTabTextColor, + fontSize: customStyles.tabs?.activeTabTextSize || defaultStyles.tabs.activeTabTextSize, + fontWeight: customStyles.tabs?.activeTabTextWeight || defaultStyles.tabs.activeTabTextWeight + }, + inActiveTabText: { + color: theme.tabs?.inactiveTabTextColor || DefaultTheme[mode].tabs.inactiveTabTextColor, + fontSize: customStyles.tabs?.inactiveTabTextSize || defaultStyles.tabs.inactiveTabTextSize, + fontWeight: customStyles.tabs?.inactiveTabTextWeight || defaultStyles.tabs.inactiveTabTextWeight + }, + activeIndicator: { + backgroundColor: theme.tabs?.indicatorColor || DefaultTheme[mode].tabs.indicatorColor, + height: customStyles.tabs?.indicatorHeight || defaultStyles.tabs.indicatorHeight + } }); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 99210e5..99b384e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -82,6 +82,11 @@ export enum events { NOTIFICATION_COUNT_EVENT = 'NOTIFICATION_COUNT_EVENT' } +export enum FilterTypes { + ALL = 'All', + UNREAD = 'Unread', +} + export const LIST_EMPTY_TEXT = 'No new notifications'; export const LIST_EMPTY_DESCRIPTION = 'Check back later for updates and alerts'; export const ERROR_TEXT = 'Oops! Something went wrong.'; @@ -154,5 +159,14 @@ export const defaultStyles = { }, clearAllIcon: { size: 16 - } + }, + tabs: { + containerHeight: 46, + activeTabTextSize: 14, + inactiveTabTextSize: 14, + activeTabTextWeight: '500', + inactiveTabTextWeight: '500', + indicatorHeight: 3, + tabPadding: 5, + }, }; diff --git a/src/utils/defaultTheme.ts b/src/utils/defaultTheme.ts index 273e381..035bb7a 100644 --- a/src/utils/defaultTheme.ts +++ b/src/utils/defaultTheme.ts @@ -31,6 +31,14 @@ const defaultTheme = { descriptionColor: COLORS[ThemeMode.LIGHT].textColor, dateColor: COLORS[ThemeMode.LIGHT].textColor, mediaContainerBackground: COLORS[ThemeMode.LIGHT].imageBackground, + }, + tabs: { + containerBackgroundColor: COLORS[ThemeMode.LIGHT].neutralColor, + activeTabBackgroundColor: COLORS[ThemeMode.LIGHT].neutralColor, + inactiveTabBackgroundColor: COLORS[ThemeMode.LIGHT].neutralColor, + activeTabTextColor: COLORS[ThemeMode.LIGHT].primaryColor, + inactiveTabTextColor: COLORS[ThemeMode.LIGHT].textColor, + indicatorColor: COLORS[ThemeMode.LIGHT].primaryColor, } }, dark: { @@ -63,6 +71,14 @@ const defaultTheme = { descriptionColor: COLORS[ThemeMode.DARK].textColor, dateColor: COLORS[ThemeMode.DARK].dateColor, mediaContainerBackground: COLORS[ThemeMode.DARK].imageBackground, + }, + tabs: { + containerBackgroundColor: COLORS[ThemeMode.DARK].neutralColor, + activeTabBackgroundColor: COLORS[ThemeMode.DARK].neutralColor, + inactiveTabBackgroundColor: COLORS[ThemeMode.DARK].neutralColor, + activeTabTextColor: COLORS[ThemeMode.DARK].primaryColor, + inactiveTabTextColor: COLORS[ThemeMode.DARK].textColor, + indicatorColor: COLORS[ThemeMode.DARK].primaryColor, } } };