Files
mobile-darmasaba/app/(application)/announcement/index.tsx
amaliadwiy 6d0203cc7d fix: hindari refetch saat mount pada halaman list pengumuman
Gunakan useRef untuk skip efek di render pertama sehingga refetch
hanya dipanggil saat state update berubah (setelah CRUD), bukan
setiap kali halaman dibuka.
2026-05-19 14:41:39 +08:00

156 lines
6.4 KiB
TypeScript

import GuideOverlay from "@/components/GuideOverlay";
import InputSearch from "@/components/inputSearch";
import Skeleton from "@/components/skeleton";
import Text from '@/components/Text';
import Styles from "@/constants/Styles";
import { apiGetAnnouncement } from "@/lib/api";
import { GUIDE_ANNOUNCEMENT } from "@/lib/guideSteps";
import { useGuide } from "@/lib/useGuide";
import { useAuthSession } from "@/providers/AuthProvider";
import { useTheme } from "@/providers/ThemeProvider";
import { MaterialIcons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, RefreshControl, View, VirtualizedList } from "react-native";
import { useSelector } from "react-redux";
type Props = {
id: string
title: string
desc: string
createdAt: string
}
export default function Announcement() {
const { token, decryptToken } = useAuthSession()
const { colors } = useTheme();
const [search, setSearch] = useState('')
const update = useSelector((state: any) => state.announcementUpdate)
const isFirstRender = useRef(true)
const { visible: guideVisible, dismiss: dismissGuide } = useGuide('announcement')
const arrSkeleton = Array.from({ length: 5 }, (_, i) => i)
const themed = {
background: { backgroundColor: colors.background },
card: { backgroundColor: colors.card, borderColor: colors.icon + '18' },
iconBox: { backgroundColor: colors.icon + '18' },
title: { color: colors.text },
desc: { color: colors.dimmed },
date: { color: colors.dimmed },
cardPressed: { backgroundColor: colors.icon + '08' },
}
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
refetch,
isRefetching
} = useInfiniteQuery({
queryKey: ['announcements', search],
queryFn: async ({ pageParam = 1 }) => {
const hasil = await decryptToken(String(token?.current))
const response = await apiGetAnnouncement({ user: hasil, search, page: pageParam })
return response.data
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.length > 0 ? allPages.length + 1 : undefined
},
})
useEffect(() => {
if (isFirstRender.current) { isFirstRender.current = false; return }
refetch()
}, [update])
const flattenedData = useMemo(() => data?.pages.flat() || [], [data])
const getItem = (_data: unknown, index: number): Props => ({
id: flattenedData[index].id,
title: flattenedData[index].title,
desc: flattenedData[index].desc,
createdAt: flattenedData[index].createdAt,
})
const renderSkeleton = () => (
<View style={Styles.announcementListSkeletonCard}>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListSkeletonHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListSkeletonTitleRow]}>
<Skeleton width={28} height={28} borderRadius={8} />
<Skeleton width={50} widthType="percent" height={12} borderRadius={6} />
</View>
<Skeleton width={15} widthType="percent" height={10} borderRadius={6} />
</View>
<Skeleton width={100} widthType="percent" height={10} borderRadius={6} />
<Skeleton width={80} widthType="percent" height={10} borderRadius={6} />
</View>
)
const renderItem = ({ item }: { item: Props }) => (
<Pressable
onPress={() => router.push(`/announcement/${item.id}`)}
style={({ pressed }) => [Styles.announcementListCard, themed.card, pressed && themed.cardPressed]}
>
<View style={[Styles.rowSpaceBetween, Styles.rowItemsCenter, Styles.announcementListCardHeader]}>
<View style={[Styles.rowItemsCenter, Styles.announcementListTitleRow]}>
<View style={[Styles.announcementListIconBox, themed.iconBox]}>
<MaterialIcons name="campaign" size={16} color={colors.icon} />
</View>
<Text style={[Styles.textDefaultSemiBold, Styles.announcementListTitleText, themed.title]} numberOfLines={1}>
{item.title}
</Text>
</View>
<Text style={[Styles.textInformation, Styles.announcementListDateText, themed.date]}>
{item.createdAt}
</Text>
</View>
<Text style={[Styles.textMediumNormal, Styles.announcementListDescText, themed.title]} numberOfLines={2}>
{item.desc.replace(/<[^>]*>?/gm, '').replace(/\r?\n|\r/g, ' ')}
</Text>
</Pressable>
)
return (
<View style={[Styles.flex1, Styles.announcementListContainer, themed.background]}>
<GuideOverlay visible={guideVisible} steps={GUIDE_ANNOUNCEMENT} onDismiss={dismissGuide} />
<InputSearch onChange={setSearch} />
<View style={[Styles.flex1, Styles.announcementListInner]}>
{isLoading && !flattenedData.length ? (
arrSkeleton.map((_, i) => (
<View key={i} style={[Styles.announcementListCard, themed.card]}>
{renderSkeleton()}
</View>
))
) : flattenedData.length > 0 ? (
<VirtualizedList
data={flattenedData}
getItemCount={() => flattenedData.length}
getItem={getItem}
renderItem={renderItem}
keyExtractor={(item, index) => String(item.id || index)}
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={Styles.announcementListSeparator} />}
refreshControl={
<RefreshControl
refreshing={isRefetching && !isFetchingNextPage}
onRefresh={refetch}
tintColor={colors.icon}
/>
}
/>
) : (
<Text style={[Styles.textDefault, Styles.textCenter, Styles.mt30, themed.desc]}>
Tidak ada pengumuman
</Text>
)}
</View>
</View>
)
}