297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import AppHeader from "@/components/AppHeader";
|
|
import ModalConfirmation from "@/components/ModalConfirmation";
|
|
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
|
import Text from "@/components/Text";
|
|
import Styles from "@/constants/Styles";
|
|
import { apiGetNotification, apiReadAllNotification, apiReadOneNotification } from "@/lib/api";
|
|
import { setUpdateNotification } from "@/lib/notificationSlice";
|
|
import { pushToPage } from "@/lib/pushToPage";
|
|
import { useAuthSession } from "@/providers/AuthProvider";
|
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
import { Feather } from "@expo/vector-icons";
|
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { router, Stack } from "expo-router";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
|
|
type Props = {
|
|
id: string
|
|
title: string
|
|
desc: string
|
|
category: string
|
|
idContent: string
|
|
isRead: boolean
|
|
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: 'clipboard', 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();
|
|
const queryClient = useQueryClient()
|
|
const dispatch = useDispatch()
|
|
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [markingAll, setMarkingAll] = useState(false)
|
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
isLoading,
|
|
refetch
|
|
} = useInfiniteQuery({
|
|
queryKey: ['notifications'],
|
|
queryFn: async ({ pageParam = 1 }) => {
|
|
const hasil = await decryptToken(String(token?.current))
|
|
const response = await apiGetNotification({ user: hasil, page: pageParam })
|
|
return response;
|
|
},
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage, allPages) => {
|
|
return lastPage.data.length > 0 ? allPages.length + 1 : undefined;
|
|
},
|
|
enabled: !!token?.current,
|
|
staleTime: 0,
|
|
})
|
|
|
|
const flatData = useMemo(() => {
|
|
return data?.pages.flatMap(page => page.data) || [];
|
|
}, [data])
|
|
|
|
const listData = useMemo<ListRow[]>(() => {
|
|
const BULAN: Record<string, number> = {
|
|
'JAN': 0, 'FEB': 1, 'MAR': 2, 'APR': 3, 'MEI': 4, 'JUN': 5,
|
|
'JUL': 6, 'AGU': 7, 'SEP': 8, 'OKT': 9, 'NOV': 10, 'DES': 11,
|
|
}
|
|
const parseDate = (str: string) => {
|
|
const [d, m, y] = str.split(' ')
|
|
return new Date(Number(y), BULAN[m] ?? 0, Number(d)).getTime()
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
dateOrder.sort((a, b) => parseDate(b) - parseDate(a))
|
|
|
|
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])
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true)
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
setRefreshing(false)
|
|
};
|
|
|
|
const hasUnread = flatData.some((item) => !item.isRead)
|
|
|
|
async function handleReadAll() {
|
|
try {
|
|
setMarkingAll(true)
|
|
const hasil = await decryptToken(String(token?.current))
|
|
await apiReadAllNotification({ user: hasil })
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
dispatch(setUpdateNotification(!updateNotification))
|
|
} catch (error) {
|
|
console.error(error)
|
|
} finally {
|
|
setMarkingAll(false)
|
|
}
|
|
}
|
|
|
|
async function handleReadNotification(id: string, category: string, idContent: string) {
|
|
try {
|
|
const hasil = await decryptToken(String(token?.current))
|
|
await apiReadOneNotification({ user: hasil, id: id })
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
pushToPage(category, idContent)
|
|
dispatch(setUpdateNotification(!updateNotification))
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
async function handleMarkOneRead(id: string) {
|
|
try {
|
|
const hasil = await decryptToken(String(token?.current))
|
|
await apiReadOneNotification({ user: hasil, id: id })
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
dispatch(setUpdateNotification(!updateNotification))
|
|
} catch (error) {
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
|
<Stack.Screen
|
|
options={{
|
|
header: () => (
|
|
<AppHeader
|
|
title="Notifikasi"
|
|
showBack={true}
|
|
onPressLeft={() => router.back()}
|
|
right={
|
|
hasUnread ? (
|
|
<Pressable
|
|
onPress={() => setShowConfirm(true)}
|
|
disabled={markingAll}
|
|
style={{ opacity: markingAll ? 0.5 : 1, padding: 4 }}
|
|
>
|
|
<Feather name="check-square" size={20} color="white" />
|
|
</Pressable>
|
|
) : undefined
|
|
}
|
|
/>
|
|
)
|
|
}}
|
|
/>
|
|
|
|
<ModalConfirmation
|
|
visible={showConfirm}
|
|
title="Tandai Semua Dibaca"
|
|
message="Semua notifikasi akan ditandai sebagai telah dibaca."
|
|
confirmText="Tandai"
|
|
cancelText="Batal"
|
|
onConfirm={() => {
|
|
setShowConfirm(false)
|
|
handleReadAll()
|
|
}}
|
|
onCancel={() => setShowConfirm(false)}
|
|
/>
|
|
|
|
|
|
<View style={[Styles.flex1, Styles.ph15, Styles.notifContainer]}>
|
|
{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}
|
|
/>
|
|
}
|
|
renderItem={({ item }) => {
|
|
if (item._type === 'header') {
|
|
return (
|
|
<View style={[Styles.rowItemsCenter, Styles.notifHeaderRow]}>
|
|
<Text style={[Styles.notifDateText, { color: colors.dimmed }]}>
|
|
{item.date}
|
|
</Text>
|
|
<View style={[Styles.notifDateSeparator, { backgroundColor: colors.icon + '20' }]} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const { icon, color } = getNotifStyle(item.category)
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
|
|
style={({ pressed }) => [Styles.notifItemRow, {
|
|
borderColor: colors.icon + '20',
|
|
backgroundColor: pressed
|
|
? colors.icon + '10'
|
|
: item.isRead
|
|
? colors.icon + '10'
|
|
: colors.card,
|
|
}]}
|
|
>
|
|
<View style={[Styles.notifIconContainer, { backgroundColor: color + '20' }]}>
|
|
<Feather name={icon} size={20} color={color} />
|
|
</View>
|
|
|
|
<View style={[Styles.flex1, Styles.notifContent]}>
|
|
<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>
|
|
{!item.isRead && (
|
|
<Pressable
|
|
onPress={(e) => {
|
|
e.stopPropagation()
|
|
handleMarkOneRead(item.id)
|
|
}}
|
|
hitSlop={8}
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, flexShrink: 0 })}
|
|
>
|
|
<Text style={Styles.notifMarkReadText}>
|
|
Tandai dibaca
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|