Files
mobile-darmasaba/app/(application)/notification.tsx
amaliadwiy 90419b5d15 feat: tambah fitur tandai terbaca per notifikasi dan ekstrak styles
- Tambah fungsi handleMarkOneRead untuk tandai satu notifikasi terbaca tanpa navigasi
- Tambah tombol "Tandai dibaca" pada tiap notifikasi yang belum terbaca
- Buat notification.styles.ts dengan 8 class styles untuk notification screen
- Daftarkan NotificationStyles ke constants/styles/index.ts
2026-05-18 11:27:49 +08:00

286 lines
11 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 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])
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>
)
}