amalia/08-mei-26 #46
@@ -1,7 +1,6 @@
|
||||
import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
||||
import Text from "@/components/Text";
|
||||
import { ColorsStatus } from "@/constants/ColorsStatus";
|
||||
import Styles from "@/constants/Styles";
|
||||
import { apiGetNotification, apiReadOneNotification } from "@/lib/api";
|
||||
import { setUpdateNotification } from "@/lib/notificationSlice";
|
||||
@@ -9,10 +8,12 @@ import { pushToPage } from "@/lib/pushToPage";
|
||||
import { useAuthSession } from "@/providers/AuthProvider";
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { router, Stack } from "expo-router";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
@@ -24,6 +25,22 @@ type Props = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type HeaderRow = { _type: 'header'; date: string }
|
||||
type ItemRow = Props & { _type: 'item' }
|
||||
type ListRow = HeaderRow | ItemRow
|
||||
|
||||
function getNotifStyle(category: string): { icon: keyof typeof Feather.glyphMap; color: string } {
|
||||
if (category === 'announcement') return { icon: 'volume-2', color: '#3B82F6' }
|
||||
if (category === 'project') return { icon: 'activity', color: '#10B981' }
|
||||
if (category.includes('/task')) return { icon: 'check-circle', color: '#8B5CF6' }
|
||||
if (category === 'division') return { icon: 'users', color: '#3B82F6' }
|
||||
if (category.includes('/discussion') || category === 'discussion-general') return { icon: 'message-square', color: '#06B6D4' }
|
||||
if (category.includes('/calendar')) return { icon: 'calendar', color: '#F59E0B' }
|
||||
if (category.includes('/document')) return { icon: 'file-text', color: '#FBBF24' }
|
||||
if (category === 'member') return { icon: 'user', color: '#1F3C88' }
|
||||
return { icon: 'bell', color: '#6B7280' }
|
||||
}
|
||||
|
||||
export default function Notification() {
|
||||
const { token, decryptToken } = useAuthSession()
|
||||
const { colors } = useTheme();
|
||||
@@ -32,7 +49,6 @@ export default function Notification() {
|
||||
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// TanStack Query for Notifications with Infinite Scroll
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
@@ -55,12 +71,31 @@ export default function Notification() {
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Flatten pages into a single data array
|
||||
const flatData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.data) || [];
|
||||
}, [data])
|
||||
|
||||
// Refetch when manual update state changes
|
||||
const listData = useMemo<ListRow[]>(() => {
|
||||
const groups: Record<string, Props[]> = {}
|
||||
const dateOrder: string[] = []
|
||||
|
||||
flatData.forEach((item) => {
|
||||
if (!groups[item.createdAt]) {
|
||||
groups[item.createdAt] = []
|
||||
dateOrder.push(item.createdAt)
|
||||
}
|
||||
groups[item.createdAt].push(item)
|
||||
})
|
||||
|
||||
const result: ListRow[] = []
|
||||
dateOrder.forEach((date) => {
|
||||
result.push({ _type: 'header', date })
|
||||
const sorted = [...groups[date]].sort((a, b) => Number(a.isRead) - Number(b.isRead))
|
||||
sorted.forEach((item) => result.push({ ...item, _type: 'item' }))
|
||||
})
|
||||
return result
|
||||
}, [flatData])
|
||||
|
||||
useEffect(() => {
|
||||
refetch()
|
||||
}, [updateNotification, refetch])
|
||||
@@ -71,16 +106,10 @@ export default function Notification() {
|
||||
setRefreshing(false)
|
||||
};
|
||||
|
||||
const loadMoreData = () => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
};
|
||||
|
||||
async function handleReadNotification(id: string, category: string, idContent: string) {
|
||||
try {
|
||||
const hasil = await decryptToken(String(token?.current))
|
||||
const response = await apiReadOneNotification({ user: hasil, id: id })
|
||||
await apiReadOneNotification({ user: hasil, id: id })
|
||||
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
pushToPage(category, idContent)
|
||||
dispatch(setUpdateNotification(!updateNotification))
|
||||
@@ -89,70 +118,113 @@ export default function Notification() {
|
||||
}
|
||||
}
|
||||
|
||||
const arrSkeleton = [0, 1, 2, 3, 4]
|
||||
|
||||
const getItem = (_data: unknown, index: number): Props => ({
|
||||
id: flatData[index]?.id,
|
||||
title: flatData[index]?.title,
|
||||
desc: flatData[index]?.desc,
|
||||
category: flatData[index]?.category,
|
||||
idContent: flatData[index]?.idContent,
|
||||
isRead: flatData[index]?.isRead,
|
||||
createdAt: flatData[index]?.createdAt,
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
||||
<View style={[Styles.p15]}>
|
||||
{
|
||||
isLoading ?
|
||||
arrSkeleton.map((item, index) => {
|
||||
return (
|
||||
<SkeletonTwoItem key={index} />
|
||||
)
|
||||
})
|
||||
:
|
||||
flatData.length > 0 ?
|
||||
<VirtualizedList
|
||||
data={flatData}
|
||||
getItemCount={() => flatData.length}
|
||||
getItem={getItem}
|
||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
||||
return (
|
||||
<BorderBottomItemVertical
|
||||
borderType="bottom"
|
||||
icon={
|
||||
<View style={[Styles.iconContent, item.isRead && ColorsStatus.secondary]}>
|
||||
<Feather name="bell" size={25} color="black" />
|
||||
</View>
|
||||
}
|
||||
title={item.title}
|
||||
rightTopInfo={item.createdAt}
|
||||
desc={item.desc}
|
||||
textColor={item.isRead ? 'gray' : colors.text}
|
||||
onPress={() => {
|
||||
handleReadNotification(item.id, item.category, item.idContent)
|
||||
}}
|
||||
bgColor={'transparent'}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
keyExtractor={(item, index) => String(index)}
|
||||
onEndReached={loadMoreData}
|
||||
onEndReachedThreshold={0.5}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.icon}
|
||||
/>
|
||||
}
|
||||
<Stack.Screen
|
||||
options={{
|
||||
header: () => (
|
||||
<AppHeader title="Notifikasi" showBack={true} onPressLeft={() => router.back()} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 10 }]}>
|
||||
{isLoading ? (
|
||||
[0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
|
||||
) : flatData.length === 0 ? (
|
||||
<View style={[Styles.contentItemCenter, Styles.mt30]}>
|
||||
<Feather name="bell-off" size={42} color={colors.icon + '40'} />
|
||||
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
|
||||
Tidak ada notifikasi
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={listData}
|
||||
keyExtractor={(item, index) => String(index)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) fetchNextPage()
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.icon}
|
||||
/>
|
||||
:
|
||||
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada data</Text>
|
||||
}
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item._type === 'header') {
|
||||
return (
|
||||
<View style={[Styles.rowItemsCenter, { marginTop: 16, marginBottom: 8 }]}>
|
||||
<Text style={{ fontSize: 11, fontWeight: '600', color: colors.dimmed, letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
||||
{item.date}
|
||||
</Text>
|
||||
<View style={{ flex: 1, height: 1, backgroundColor: colors.icon + '20', marginLeft: 8 }} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const { icon, color } = getNotifStyle(item.category)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
|
||||
style={({ pressed }) => [{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.icon + '20',
|
||||
backgroundColor: pressed
|
||||
? colors.icon + '10'
|
||||
: item.isRead
|
||||
? colors.icon + '10'
|
||||
: colors.card,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 6,
|
||||
}]}
|
||||
>
|
||||
{/* Colored icon */}
|
||||
<View style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
backgroundColor: color + '20',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Feather name={icon} size={20} color={color} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={[Styles.flex1, { marginLeft: 10 }]}>
|
||||
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}>
|
||||
<View style={[Styles.flex1, Styles.mr10]}>
|
||||
<Text
|
||||
style={[Styles.textDefaultSemiBold, { color: item.isRead ? colors.dimmed : colors.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.desc}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user