From fd417f86c9f549eea5915fa8e13d09b18c233fa2 Mon Sep 17 00:00:00 2001 From: Gowtham G Date: Sat, 19 Sep 2026 22:45:02 +0530 Subject: [PATCH] refactor(api): replace axios and zustand stores with native fetch and tanstack query --- package-lock.json | 97 +++----- package.json | 5 +- src/api/hooks.ts | 124 +++++++++++ src/api/index.ts | 84 +++++-- src/api/queryClient.ts | 23 ++ src/app/_layout.tsx | 32 ++- src/components/LinkPreview/index.tsx | 28 +-- src/screens/Article/index.tsx | 24 +- src/screens/Common/ArticleList/index.tsx | 63 +++--- src/screens/LandingFeed/index.tsx | 44 ++-- src/screens/LatestFeed/index.tsx | 44 ++-- src/screens/Search/index.tsx | 94 ++++---- src/screens/Videos/index.tsx | 70 +++--- src/store/articles/article.ts | 61 ----- src/store/articles/feed.ts | 270 ----------------------- src/store/helpers.ts | 32 --- src/store/videos/feed.ts | 87 -------- 17 files changed, 432 insertions(+), 750 deletions(-) create mode 100644 src/api/hooks.ts create mode 100644 src/api/queryClient.ts delete mode 100644 src/store/articles/article.ts delete mode 100644 src/store/articles/feed.ts delete mode 100644 src/store/helpers.ts delete mode 100644 src/store/videos/feed.ts diff --git a/package-lock.json b/package-lock.json index 8eeea01b..de4b5e04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@react-native-firebase/app": "^26.4.0", "@react-native-firebase/crashlytics": "^26.4.0", "@shopify/flash-list": "2.0.2", - "axios": "1.7.4", + "@tanstack/react-query": "^5.103.1", "expo": "^57.0.24", "expo-build-properties": "~57.0.21", "expo-clipboard": "~57.0.2", @@ -54,8 +54,7 @@ "react-syntax-highlighter": "15.5.0", "turndown": "7.1.3", "url-parse": "1.5.10", - "use-sync-external-store": "1.7.0", - "zustand": "5.0.15" + "use-sync-external-store": "1.7.0" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -6334,6 +6333,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz", + "integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz", + "integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -6820,17 +6845,6 @@ "node": ">= 4.0.0" } }, - "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -9830,26 +9844,6 @@ "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", "license": "MIT" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/fontfaceobserver": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", @@ -14339,12 +14333,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -16814,35 +16802,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zustand": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", - "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 7be93dda..9bdbf631 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@react-native-firebase/app": "^26.4.0", "@react-native-firebase/crashlytics": "^26.4.0", "@shopify/flash-list": "2.0.2", - "axios": "1.7.4", + "@tanstack/react-query": "^5.103.1", "expo": "^57.0.24", "expo-build-properties": "~57.0.21", "expo-clipboard": "~57.0.2", @@ -67,8 +67,7 @@ "react-syntax-highlighter": "15.5.0", "turndown": "7.1.3", "url-parse": "1.5.10", - "use-sync-external-store": "1.7.0", - "zustand": "5.0.15" + "use-sync-external-store": "1.7.0" }, "devDependencies": { "@babel/core": "^7.29.0", diff --git a/src/api/hooks.ts b/src/api/hooks.ts new file mode 100644 index 00000000..6dd82f53 --- /dev/null +++ b/src/api/hooks.ts @@ -0,0 +1,124 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { DEFAULT_PAGE_SIZE } from "../utils/const"; +import { getImageSize } from "../utils/image"; +import { processMarkdownContent } from "../utils/markdown"; +import { + fetchContentFromURL, + getArticle, + getArticlesList, + getVideos, + searchArticles, +} from "./index"; +import { ArticleFeedApiStates } from "./types"; + +export const articleKeys = { + latest: ["articles", "latest"] as const, + featured: ["articles", "featured"] as const, + search: (q: string) => ["articles", "search", q] as const, + detail: (id: number) => ["article", id] as const, +}; + +export const videoKeys = { + list: ["videos"] as const, +}; + +export const linkPreviewKeys = { + title: (url: string) => ["link-preview", url] as const, +}; + +const getNextPageParam = (lastPage: T[], allPages: T[][]) => { + if (lastPage.length < DEFAULT_PAGE_SIZE) { + return undefined; + } + return allPages.length + 1; +}; + +export function useLatestArticles() { + return useInfiniteQuery({ + queryKey: articleKeys.latest, + queryFn: ({ pageParam, signal }) => + getArticlesList( + ArticleFeedApiStates.Fresh, + pageParam, + DEFAULT_PAGE_SIZE, + { + signal, + }, + ), + initialPageParam: 1, + getNextPageParam, + }); +} + +export function useFeaturedArticles() { + return useInfiniteQuery({ + queryKey: articleKeys.featured, + queryFn: ({ pageParam, signal }) => + getArticlesList(undefined, pageParam, DEFAULT_PAGE_SIZE, { signal }), + initialPageParam: 1, + getNextPageParam, + }); +} + +export function useVideos() { + return useInfiniteQuery({ + queryKey: videoKeys.list, + queryFn: ({ pageParam, signal }) => + getVideos(pageParam, DEFAULT_PAGE_SIZE, { signal }), + initialPageParam: 1, + getNextPageParam, + }); +} + +export function useSearchArticles(query: string) { + const q = query.trim(); + return useInfiniteQuery({ + queryKey: articleKeys.search(q), + queryFn: ({ pageParam, signal }) => + searchArticles(q, pageParam, DEFAULT_PAGE_SIZE, { signal }), + initialPageParam: 1, + getNextPageParam, + enabled: q.length > 0, + }); +} + +export function useArticleDetail(id: number) { + return useQuery({ + queryKey: articleKeys.detail(id), + queryFn: async ({ signal }) => { + const article = await getArticle(id, { signal }); + const coverImageSize = await getImageSize(article.cover_image); + let aspectRatio = 0; + if (coverImageSize.height > 0) { + aspectRatio = coverImageSize.width / coverImageSize.height; + } + const md = processMarkdownContent(article.body_markdown); + return { + ...article, + body_markdown: md, + cover_image_width: coverImageSize.width, + cover_image_height: coverImageSize.height, + cover_image_aspect_ratio: aspectRatio, + }; + }, + enabled: Number.isFinite(id) && id > 0, + }); +} + +export function useLinkPreviewTitle(url: string) { + return useQuery({ + queryKey: linkPreviewKeys.title(url), + queryFn: async ({ signal }) => { + const html = await fetchContentFromURL(url, { signal }); + const matches = /(.*?)<\/title>/i.exec(html); + if (matches?.[1]) { + return matches[1]; + } + return "External URL"; + }, + staleTime: 24 * 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + retry: false, + enabled: url.length > 0, + }); +} diff --git a/src/api/index.ts b/src/api/index.ts index 826275b9..65e02254 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,33 +1,80 @@ -import axios from "axios"; import { API_BASE_URL, DEFAULT_PAGE_SIZE, NETWORK_TIMEOUT_MS, } from "../utils/const"; import { buildURLParams } from "../utils/url"; -import type { ArticleFeedApiStates } from "./types"; +import type { + ApiArticleFeedItem, + ApiArticleItem, + ApiVideoListItem, + ArticleFeedApiStates, +} from "./types"; + +interface RequestOptions { + signal?: AbortSignal; +} + +const USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"; + +async function doFetch(url: string, init?: RequestInit): Promise<Response> { + const incomingSignal = init?.signal; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS); + const onIncomingAbort = () => controller.abort(); + incomingSignal?.addEventListener("abort", onIncomingAbort); + + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + if (!response.ok) { + throw new Error( + `Request failed: ${response.status} ${response.statusText} for ${url}`, + ); + } + return response; + } finally { + clearTimeout(timeoutId); + incomingSignal?.removeEventListener("abort", onIncomingAbort); + } +} + +async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> { + const response = await doFetch(url, init); + return (await response.json()) as T; +} + +async function fetchText(url: string, init?: RequestInit): Promise<string> { + const response = await doFetch(url, init); + return response.text(); +} export const getArticlesList = ( state?: ArticleFeedApiStates, page = 1, perPage = DEFAULT_PAGE_SIZE, + options?: RequestOptions, ) => { const params = buildURLParams({ state, page, per_page: perPage }); - return axios.get(`${API_BASE_URL}/articles?${params}`, { - timeout: NETWORK_TIMEOUT_MS, + return fetchJSON<ApiArticleFeedItem[]>(`${API_BASE_URL}/articles?${params}`, { + signal: options?.signal, }); }; -export const getVideos = (page: number, perPage: number) => { +export const getVideos = ( + page: number, + perPage: number, + options?: RequestOptions, +) => { const params = buildURLParams({ page, per_page: perPage }); - return axios.get(`${API_BASE_URL}/videos?${params}`, { - timeout: NETWORK_TIMEOUT_MS, + return fetchJSON<ApiVideoListItem[]>(`${API_BASE_URL}/videos?${params}`, { + signal: options?.signal, }); }; -export const getArticle = (id: number) => { - return axios.get(`${API_BASE_URL}/articles/${id}`, { - timeout: NETWORK_TIMEOUT_MS, +export const getArticle = (id: number, options?: RequestOptions) => { + return fetchJSON<ApiArticleItem>(`${API_BASE_URL}/articles/${id}`, { + signal: options?.signal, }); }; @@ -35,19 +82,20 @@ export const searchArticles = ( q: string, page = 1, perPage = DEFAULT_PAGE_SIZE, + options?: RequestOptions, ) => { const params = buildURLParams({ q, page, per_page: perPage }); - return axios.get(`${API_BASE_URL}/articles/search?${params}`, { - timeout: NETWORK_TIMEOUT_MS, - }); + return fetchJSON<ApiArticleFeedItem[]>( + `${API_BASE_URL}/articles/search?${params}`, + { signal: options?.signal }, + ); }; -export const fetchContentFromURL = (url: string) => { - return axios.get<string>(url, { +export const fetchContentFromURL = (url: string, options?: RequestOptions) => { + return fetchText(url, { headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "User-Agent": USER_AGENT, }, - timeout: NETWORK_TIMEOUT_MS, + signal: options?.signal, }); }; diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts new file mode 100644 index 00000000..62584a80 --- /dev/null +++ b/src/api/queryClient.ts @@ -0,0 +1,23 @@ +import NetInfo from "@react-native-community/netinfo"; +import { QueryClient, onlineManager } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: 2, + refetchOnReconnect: true, + refetchOnWindowFocus: false, + }, + }, +}); + +export function setupQueryOnlineManager(): void { + onlineManager.setEventListener((setOnline) => { + const subscription = NetInfo.addEventListener((state) => { + setOnline(!!state.isConnected); + }); + return subscription; + }); +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 1d7787bb..bcb79484 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { DefaultTheme, @@ -17,6 +18,7 @@ import { StatusBar, type StatusBarStyle } from "react-native"; import { Provider as PaperProvider } from "react-native-paper"; import { SafeAreaProvider } from "react-native-safe-area-context"; import MaterialIcon from "react-native-vector-icons/MaterialIcons"; +import { queryClient, setupQueryOnlineManager } from "../api/queryClient"; import useUserColorScheme from "../hooks/useUserColorScheme"; import { COLOR_SCHEME_VALUES } from "../mmkv/colorScheme"; import { DarkTheme, LightTheme } from "../theme"; @@ -73,6 +75,10 @@ const RootLayout: FunctionComponent = () => { }; }, [isDark, theme]); + useEffect(() => { + setupQueryOnlineManager(); + }, []); + useEffect(() => { StatusBar.setBackgroundColor(theme.colors.elevation.level2); StatusBar.setBarStyle(statusBarStyle); @@ -103,18 +109,20 @@ const RootLayout: FunctionComponent = () => { }} > <SafeAreaProvider> - <ThemeProvider value={navigationTheme}> - <Stack screenOptions={{ headerShown: false }}> - <Stack.Screen name="(tabs)" /> - <Stack.Screen name="article/[id]" /> - <Stack.Screen name="video/[id]" /> - <Stack.Screen name="bookmarks" /> - <Stack.Screen name="search" /> - <Stack.Screen name="settings" /> - <Stack.Screen name="about" /> - <Stack.Screen name="terms" /> - </Stack> - </ThemeProvider> + <QueryClientProvider client={queryClient}> + <ThemeProvider value={navigationTheme}> + <Stack screenOptions={{ headerShown: false }}> + <Stack.Screen name="(tabs)" /> + <Stack.Screen name="article/[id]" /> + <Stack.Screen name="video/[id]" /> + <Stack.Screen name="bookmarks" /> + <Stack.Screen name="search" /> + <Stack.Screen name="settings" /> + <Stack.Screen name="about" /> + <Stack.Screen name="terms" /> + </Stack> + </ThemeProvider> + </QueryClientProvider> </SafeAreaProvider> </PaperProvider> ); diff --git a/src/components/LinkPreview/index.tsx b/src/components/LinkPreview/index.tsx index b15f4684..7788c63c 100644 --- a/src/components/LinkPreview/index.tsx +++ b/src/components/LinkPreview/index.tsx @@ -1,8 +1,7 @@ -import { type FunctionComponent, memo, useEffect, useState } from "react"; +import { type FunctionComponent, memo } from "react"; import { Linking, StyleSheet, View } from "react-native"; import { Surface, Text, TouchableRipple, useTheme } from "react-native-paper"; -import { fetchContentFromURL } from "../../api"; -import { logError } from "../../utils/log"; +import { useLinkPreviewTitle } from "../../api/hooks"; interface Props { url: string; @@ -11,27 +10,8 @@ interface Props { const LinkPreview: FunctionComponent<Props> = ({ url }) => { const theme = useTheme(); - const [title, setTitle] = useState("External URL"); - - useEffect(() => { - const fetchContent = async () => { - try { - const resp = await fetchContentFromURL(url); - const html = resp.data; - const titleRegex = new RegExp(/<title>(.*?)<\/title>/gim); - - const matches = titleRegex.exec(html); - if (matches && matches.length > 0) { - setTitle(matches[1]); - } - } catch (e) { - logError(e as Error, "Error while fetching url for preview"); - console.error(e); - } - }; - - fetchContent(); - }, [url]); + const { data } = useLinkPreviewTitle(url); + const title = data ?? "External URL"; const onPress = () => { Linking.openURL(url); diff --git a/src/screens/Article/index.tsx b/src/screens/Article/index.tsx index 4404c6c6..147d5d32 100644 --- a/src/screens/Article/index.tsx +++ b/src/screens/Article/index.tsx @@ -1,5 +1,5 @@ import { useNetInfo } from "@react-native-community/netinfo"; -import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { Fragment, type FunctionComponent, @@ -24,6 +24,7 @@ import { useAnimatedStyle, useSharedValue, } from "react-native-reanimated"; +import { useArticleDetail } from "../../api/hooks"; import ArticleAnimatedCover from "../../components/ArticleAnimatedCover"; import RenderMarkdownAnimatedFlatList from "../../components/Markdown/AnimatedFlatList"; import NetworkBanner from "../../components/NetworkBanner"; @@ -35,7 +36,6 @@ import { removeBookmark, savePostToBookmarks, } from "../../mmkv/bookmark"; -import useArticleStore from "../../store/articles/article"; import { HELP_TEXT } from "../../utils/const"; import { logError } from "../../utils/log"; import { firstParam, parseIdParam, parseTagsParam } from "../../utils/router"; @@ -121,25 +121,7 @@ const ArticleScreen: FunctionComponent = () => { }; }); - const { article, fetchArticle, resetArticle, error } = useArticleStore( - (state) => ({ - article: state.article, - fetchArticle: state.fetchArticle, - resetArticle: state.reset, - error: state.error, - }), - ); - - useFocusEffect( - useCallback(() => { - fetchArticle(id); - - return () => { - // Resets article state - resetArticle(); - }; - }, [id, resetArticle, fetchArticle]), - ); + const { data: article, isError: error } = useArticleDetail(id); const onBackActionPress = useCallback(() => { router.back(); diff --git a/src/screens/Common/ArticleList/index.tsx b/src/screens/Common/ArticleList/index.tsx index df2bf94e..582fb650 100644 --- a/src/screens/Common/ArticleList/index.tsx +++ b/src/screens/Common/ArticleList/index.tsx @@ -5,48 +5,46 @@ import { type FunctionComponent, memo, useCallback, - useEffect, useRef, useState, } from "react"; -import { StyleSheet, View } from "react-native"; +import { StyleSheet, ToastAndroid, View } from "react-native"; import type { ApiArticleFeedItem } from "../../../api/types"; import HomeAppbar from "../../../components/Appbar/HomeAppbar"; import ArticleFeed from "../../../components/ArticleFeed"; import ListFooterLoader from "../../../components/List/ListFooterLoader"; import NetworkBanner from "../../../components/NetworkBanner"; import FeedSkeleton from "../../../components/Skeleton/FeedSkeleton"; +import { HELP_TEXT } from "../../../utils/const"; import { articleRoute } from "../../../utils/router"; interface ArticleFeedProps { title: string; articles: ApiArticleFeedItem[]; - fetchArticles: (page: number) => void; - refreshing: boolean; - refreshArticles: () => void; - page: number; - loading: boolean; - error: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isPending: boolean; + isRefetching: boolean; + refetch: () => Promise<{ isError: boolean }>; + isError: boolean; } const ArticleFeedScreen: FunctionComponent<ArticleFeedProps> = ({ articles, - fetchArticles, - refreshing, - refreshArticles, - page, - loading, - error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + refetch, + isError, }) => { const listRef = useRef<FlashListRef<ApiArticleFeedItem>>(null); const [showNetworkBanner, setShowNetworkBanner] = useState(true); const netInfo = useNetInfo(); - useEffect(() => { - fetchArticles(page); - }, [page, fetchArticles]); - const router = useRouter(); const onItemClick = useCallback( @@ -74,14 +72,25 @@ const ArticleFeedScreen: FunctionComponent<ArticleFeedProps> = ({ const onEndReached = useCallback(() => { if (articles.length < 1) return; + if (!hasNextPage || isFetchingNextPage) return; + + fetchNextPage(); + }, [articles.length, hasNextPage, isFetchingNextPage, fetchNextPage]); - const next = page + 1; - fetchArticles(next); - }, [articles.length, page, fetchArticles]); + const onRefresh = useCallback(async () => { + const result = await refetch(); + if (!result.isError) { + ToastAndroid.showWithGravity( + HELP_TEXT.FEED_REFRESHED, + ToastAndroid.SHORT, + ToastAndroid.TOP, + ); + } + }, [refetch]); const renderFooter = useCallback( - () => <ListFooterLoader loading={loading} />, - [loading], + () => <ListFooterLoader loading={isFetchingNextPage} />, + [isFetchingNextPage], ); const onCloseBanner = useCallback(() => setShowNetworkBanner(false), []); @@ -90,11 +99,11 @@ const ArticleFeedScreen: FunctionComponent<ArticleFeedProps> = ({ <View style={styles.container}> <HomeAppbar /> <NetworkBanner - visible={error && !netInfo.isConnected && showNetworkBanner} + visible={isError && !netInfo.isConnected && showNetworkBanner} showCloseAction onCloseActionPress={onCloseBanner} /> - {loading && articles.length < 1 ? ( + {isPending && articles.length < 1 ? ( <FeedSkeleton /> ) : ( <View style={styles.listWrapper}> @@ -103,8 +112,8 @@ const ArticleFeedScreen: FunctionComponent<ArticleFeedProps> = ({ data={articles} onItemClick={onItemClick} listProps={{ - refreshing, - onRefresh: refreshArticles, + refreshing: isRefetching, + onRefresh: onRefresh, onEndReached: onEndReached, onEndReachedThreshold: 0.75, getItemType: (item) => item.type_of, diff --git a/src/screens/LandingFeed/index.tsx b/src/screens/LandingFeed/index.tsx index 86c00256..1d0bad20 100644 --- a/src/screens/LandingFeed/index.tsx +++ b/src/screens/LandingFeed/index.tsx @@ -1,36 +1,32 @@ -import { memo } from "react"; -import useArticleFeedStore from "../../store/articles/feed"; +import { memo, useMemo } from "react"; +import { useFeaturedArticles } from "../../api/hooks"; import ArticleFeedScreen from "../Common/ArticleList"; const LandingScreen = () => { const { - articles, - fetchArticles, - refreshing, - refreshArticles, - page, - loading, - error, - } = useArticleFeedStore((state) => ({ - articles: state.featured.articles, - fetchArticles: state.featured.fetchFeaturedArticles, - refreshing: state.featured.refreshing, - refreshArticles: state.featured.refreshFeaturedArticles, - page: state.featured.page, - loading: state.featured.loading, - error: state.featured.error, - })); + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + refetch, + isError, + } = useFeaturedArticles(); + + const articles = useMemo(() => data?.pages.flat() ?? [], [data]); return ( <ArticleFeedScreen title="Featured" articles={articles} - fetchArticles={fetchArticles} - refreshing={refreshing} - refreshArticles={refreshArticles} - page={page} - loading={loading} - error={error} + fetchNextPage={fetchNextPage} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + isPending={isPending} + isRefetching={isRefetching} + refetch={refetch} + isError={isError} /> ); }; diff --git a/src/screens/LatestFeed/index.tsx b/src/screens/LatestFeed/index.tsx index c4620889..297f09a4 100644 --- a/src/screens/LatestFeed/index.tsx +++ b/src/screens/LatestFeed/index.tsx @@ -1,36 +1,32 @@ -import { type FunctionComponent, memo } from "react"; -import useArticleFeedStore from "../../store/articles/feed"; +import { type FunctionComponent, memo, useMemo } from "react"; +import { useLatestArticles } from "../../api/hooks"; import ArticleFeedScreen from "../Common/ArticleList"; const LatestScreen: FunctionComponent = () => { const { - articles, - fetchArticles, - refreshing, - refreshArticles, - page, - loading, - error, - } = useArticleFeedStore((state) => ({ - articles: state.latest.articles, - fetchArticles: state.latest.fetchLatestArticles, - refreshing: state.latest.refreshing, - refreshArticles: state.latest.refreshLatestArticles, - page: state.latest.page, - loading: state.latest.loading, - error: state.latest.error, - })); + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + refetch, + isError, + } = useLatestArticles(); + + const articles = useMemo(() => data?.pages.flat() ?? [], [data]); return ( <ArticleFeedScreen title="Latest" articles={articles} - fetchArticles={fetchArticles} - refreshing={refreshing} - refreshArticles={refreshArticles} - page={page} - loading={loading} - error={error} + fetchNextPage={fetchNextPage} + hasNextPage={hasNextPage ?? false} + isFetchingNextPage={isFetchingNextPage} + isPending={isPending} + isRefetching={isRefetching} + refetch={refetch} + isError={isError} /> ); }; diff --git a/src/screens/Search/index.tsx b/src/screens/Search/index.tsx index bda80ef5..10dbaf12 100644 --- a/src/screens/Search/index.tsx +++ b/src/screens/Search/index.tsx @@ -10,8 +10,9 @@ import { } from "react"; import { View } from "react-native"; import type { TextInput } from "react-native"; -import { StyleSheet } from "react-native"; +import { StyleSheet, ToastAndroid } from "react-native"; import { List, Searchbar } from "react-native-paper"; +import { useSearchArticles } from "../../api/hooks"; import type { ApiArticleFeedItem } from "../../api/types"; import ArticleFeed from "../../components/ArticleFeed"; import ListFooterLoader from "../../components/List/ListFooterLoader"; @@ -20,7 +21,7 @@ import { addItemToRecentSearchHistory, getRecentSearchHistory, } from "../../mmkv/searchHistory"; -import useArticleFeedStore from "../../store/articles/feed"; +import { HELP_TEXT } from "../../utils/const"; import { articleRoute } from "../../utils/router"; const SearchScreen: FunctionComponent = () => { @@ -31,23 +32,21 @@ const SearchScreen: FunctionComponent = () => { ); const searchRef = useRef<TextInput>(null); const [searchQuery, setSearchQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); const { - articles, - searchArticles, - refreshing, - refreshSearch, - page, - loading, - reset, - } = useArticleFeedStore((state) => ({ - articles: state.search.articles, - searchArticles: state.search.searchArticles, - refreshing: state.search.refreshing, - refreshSearch: state.search.refreshSearch, - page: state.search.page, - loading: state.search.loading, - reset: state.search.reset, - })); + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isFetching, + isRefetching, + refetch, + } = useSearchArticles(submittedQuery); + + const articles = useMemo(() => data?.pages.flat() ?? [], [data]); + const isInitialLoading = + submittedQuery.length > 0 && isPending && articles.length < 1; useEffect(() => { focusSearchInput(); @@ -58,9 +57,8 @@ const SearchScreen: FunctionComponent = () => { }, []); const onBackIconPress = useCallback(() => { - reset(); router.back(); - }, [reset, router]); + }, [router]); const onSubmit = useCallback(() => { if (searchQuery.trim().length < 1) { @@ -68,7 +66,7 @@ const SearchScreen: FunctionComponent = () => { } const q = searchQuery.trim(); - searchArticles(q, 1); + setSubmittedQuery(q); addItemToRecentSearchHistory(q); setSearchHistoryItems(getRecentSearchHistory()); if (articles.length > 0 && listRef.current) { @@ -77,11 +75,18 @@ const SearchScreen: FunctionComponent = () => { index: 0, }); } - }, [searchQuery, searchArticles, articles.length]); - - const refreshArticles = useCallback(() => { - refreshSearch(searchQuery); - }, [refreshSearch, searchQuery]); + }, [searchQuery, articles.length]); + + const refreshArticles = useCallback(async () => { + const result = await refetch(); + if (!result.isError) { + ToastAndroid.showWithGravity( + HELP_TEXT.FEED_REFRESHED, + ToastAndroid.SHORT, + ToastAndroid.TOP, + ); + } + }, [refetch]); const onItemClick = useCallback( (id: number) => { @@ -108,35 +113,32 @@ const SearchScreen: FunctionComponent = () => { const onEndReached = useCallback(() => { if (articles.length < 1) return; + if (!hasNextPage || isFetchingNextPage) return; - const next = page + 1; - searchArticles(searchQuery, next); - }, [articles.length, page, searchArticles, searchQuery]); + fetchNextPage(); + }, [articles.length, hasNextPage, isFetchingNextPage, fetchNextPage]); const focusSearchInput = useCallback(() => { searchRef.current?.focus(); }, []); - const onSearchHistoryItemPress = useCallback( - (item: string) => { - setSearchQuery(item); - searchArticles(item, 1); - addItemToRecentSearchHistory(item); - setSearchHistoryItems(getRecentSearchHistory()); - }, - [searchArticles], - ); + const onSearchHistoryItemPress = useCallback((item: string) => { + setSearchQuery(item); + setSubmittedQuery(item); + addItemToRecentSearchHistory(item); + setSearchHistoryItems(getRecentSearchHistory()); + }, []); const renderFooter = useCallback( - () => <ListFooterLoader loading={loading} />, - [loading], + () => <ListFooterLoader loading={isFetchingNextPage} />, + [isFetchingNextPage], ); const renderHistoryIcon = useCallback(() => <List.Icon icon="history" />, []); const listProps = useMemo( () => ({ - refreshing, + refreshing: isRefetching, onRefresh: refreshArticles, onEndReached: onEndReached, onEndReachedThreshold: 0.75 as const, @@ -144,7 +146,7 @@ const SearchScreen: FunctionComponent = () => { ListFooterComponent: renderFooter, contentContainerStyle: styles.listContainer, }), - [refreshing, refreshArticles, onEndReached, renderFooter], + [isRefetching, refreshArticles, onEndReached, renderFooter], ); return ( @@ -159,10 +161,12 @@ const SearchScreen: FunctionComponent = () => { placeholder="Search articles" onChangeText={onSearchTextChange} value={searchQuery} - loading={loading} + loading={submittedQuery.length > 0 && isFetching} onClearIconPress={focusSearchInput} /> - {!loading && articles.length < 1 && searchHistoryItems.length > 0 ? ( + {!isInitialLoading && + articles.length < 1 && + searchHistoryItems.length > 0 ? ( <List.Section> <List.Subheader>History</List.Subheader> <View style={{ paddingHorizontal: 12 }}> @@ -177,7 +181,7 @@ const SearchScreen: FunctionComponent = () => { </View> </List.Section> ) : null} - {loading && articles.length < 1 ? ( + {isInitialLoading ? ( <FeedSkeleton /> ) : ( <View style={styles.listWrapper}> diff --git a/src/screens/Videos/index.tsx b/src/screens/Videos/index.tsx index 1a08f8ef..63909f7f 100644 --- a/src/screens/Videos/index.tsx +++ b/src/screens/Videos/index.tsx @@ -5,18 +5,18 @@ import { type FunctionComponent, memo, useCallback, - useEffect, + useMemo, useState, } from "react"; -import { StyleSheet, View } from "react-native"; +import { StyleSheet, ToastAndroid, View } from "react-native"; +import { useVideos } from "../../api/hooks"; import type { ApiVideoListItem } from "../../api/types"; import HomeAppbar from "../../components/Appbar/HomeAppbar"; import ListFooterLoader from "../../components/List/ListFooterLoader"; import NetworkBanner from "../../components/NetworkBanner"; import FeedSkeleton from "../../components/Skeleton/FeedSkeleton"; import VideoFeedItem from "../../components/VideoFeedItem"; -import useVideoFeedStore from "../../store/videos/feed"; -import { DEV_TO_HOST } from "../../utils/const"; +import { DEV_TO_HOST, HELP_TEXT } from "../../utils/const"; import { videoRoute } from "../../utils/router"; const FeedSeparator = () => <View style={styles.separator} />; @@ -29,26 +29,17 @@ const VideosScreen: FunctionComponent = () => { const netInfo = useNetInfo(); const { - videos, - fetchVideos, - refreshing, - refreshVideos, - page, - loading, - error, - } = useVideoFeedStore((state) => ({ - videos: state.videos, - fetchVideos: state.fetchVideos, - refreshing: state.refreshing, - refreshVideos: state.refreshVideos, - page: state.page, - loading: state.loading, - error: state.error, - })); - - useEffect(() => { - fetchVideos(page); - }, [page, fetchVideos]); + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + refetch, + isError, + } = useVideos(); + + const videos = useMemo(() => data?.pages.flat() ?? [], [data]); const onItemClick = useCallback( (id: number) => { @@ -82,9 +73,22 @@ const VideosScreen: FunctionComponent = () => { ); const onEndReached = useCallback(() => { - const next = page + 1; - fetchVideos(next); - }, [page, fetchVideos]); + if (videos.length < 1) return; + if (!hasNextPage || isFetchingNextPage) return; + + fetchNextPage(); + }, [videos.length, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const onRefresh = useCallback(async () => { + const result = await refetch(); + if (!result.isError) { + ToastAndroid.showWithGravity( + HELP_TEXT.FEED_REFRESHED, + ToastAndroid.SHORT, + ToastAndroid.TOP, + ); + } + }, [refetch]); const renderItem: ListRenderItem<ApiVideoListItem> = useCallback( ({ item }) => { @@ -105,8 +109,8 @@ const VideosScreen: FunctionComponent = () => { ); const renderFooter = useCallback( - () => <ListFooterLoader loading={loading} />, - [loading], + () => <ListFooterLoader loading={isFetchingNextPage} />, + [isFetchingNextPage], ); const onCloseBanner = useCallback(() => setShowNetworkBanner(false), []); @@ -115,11 +119,11 @@ const VideosScreen: FunctionComponent = () => { <View style={styles.container}> <HomeAppbar isVideoListScreen /> <NetworkBanner - visible={error && !netInfo.isConnected && showNetworkBanner} + visible={isError && !netInfo.isConnected && showNetworkBanner} showCloseAction onCloseActionPress={onCloseBanner} /> - {loading && videos.length < 1 ? ( + {isPending && videos.length < 1 ? ( <FeedSkeleton /> ) : ( <View style={styles.listWrapper}> @@ -128,8 +132,8 @@ const VideosScreen: FunctionComponent = () => { data={videos} renderItem={renderItem} keyExtractor={keyExtractor} - refreshing={refreshing} - onRefresh={refreshVideos} + refreshing={isRefetching} + onRefresh={onRefresh} onEndReached={onEndReached} onEndReachedThreshold={0.75} ListFooterComponent={renderFooter} diff --git a/src/store/articles/article.ts b/src/store/articles/article.ts deleted file mode 100644 index 7cba4966..00000000 --- a/src/store/articles/article.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/vanilla/shallow"; -import { getArticle } from "../../api"; -import type { ApiArticleItem } from "../../api/types"; -import { getImageSize } from "../../utils/image"; -import { processMarkdownContent } from "../../utils/markdown"; - -export interface ArticleState { - article?: ApiArticleItem; - loading: boolean; - error: boolean; - fetchArticle: (id: number) => void; - reset: () => void; -} - -const useArticleStore = createWithEqualityFn<ArticleState>()( - (set) => ({ - article: undefined, - loading: false, - error: false, - fetchArticle: async (id: number) => { - set({ loading: true, error: false }); - try { - const response = await getArticle(id); - const article: ApiArticleItem = await response.data; - - const coverImageSize = await getImageSize(article.cover_image); - let aspectRatio = 0; - if (coverImageSize.height > 0) { - aspectRatio = coverImageSize.width / coverImageSize.height; - } - - const md = processMarkdownContent(article.body_markdown); - - set({ - loading: false, - error: false, - article: { - ...article, - body_markdown: md, - cover_image_width: coverImageSize.width, - cover_image_height: coverImageSize.height, - cover_image_aspect_ratio: aspectRatio, - }, - }); - } catch (e) { - set({ loading: false, error: true }); - } - }, - reset: () => { - set({ - loading: false, - error: false, - article: undefined, - }); - }, - }), - shallow, -); - -export default useArticleStore; diff --git a/src/store/articles/feed.ts b/src/store/articles/feed.ts deleted file mode 100644 index 7d88c61c..00000000 --- a/src/store/articles/feed.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { ToastAndroid } from "react-native"; -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/vanilla/shallow"; -import { getArticlesList, searchArticles } from "../../api"; -import { type ApiArticleFeedItem, ArticleFeedApiStates } from "../../api/types"; -import { perfArrayConcat } from "../../utils/array"; -import { DEFAULT_PAGE_SIZE, HELP_TEXT } from "../../utils/const"; -import { - type CommonState, - setErrorState, - setFetchingState, - setRefreshingState, -} from "../helpers"; - -export interface ArticleFeedStateBase extends CommonState { - articles: ApiArticleFeedItem[]; - page: number; -} - -export interface ArticleFeedState { - latest: ArticleFeedStateBase & { - fetchLatestArticles: (page?: number) => void; - refreshLatestArticles: () => void; - }; - featured: ArticleFeedStateBase & { - fetchFeaturedArticles: (page?: number) => void; - refreshFeaturedArticles: () => void; - }; - search: ArticleFeedStateBase & { - searchArticles: (q: string, page?: number) => void; - refreshSearch: (q: string) => void; - reset: () => void; - }; -} - -const BASE_STATE: ArticleFeedStateBase = { - articles: [], - loading: false, - error: false, - refreshing: false, - page: 1, -}; - -// TODO: reuse common behavior -const useArticleFeedStore = createWithEqualityFn<ArticleFeedState>()( - (set, get) => ({ - latest: { - ...BASE_STATE, - fetchLatestArticles: async (page = 1) => { - const { loading, articles: oldArticles } = get().latest; - if (loading) { - return; - } - - set((state) => ({ ...state, latest: setFetchingState(state.latest) })); - - try { - const response = await getArticlesList( - ArticleFeedApiStates.Fresh, - page, - DEFAULT_PAGE_SIZE, - ); - const responseArticles: ApiArticleFeedItem[] = await response.data; - let articles = responseArticles; - if (page > 1) { - articles = perfArrayConcat(oldArticles, responseArticles); - } - set((state) => ({ - ...state, - latest: { - ...state.latest, - ...BASE_STATE, - page, - articles, - }, - })); - } catch (e) { - set((state) => ({ ...state, latest: setErrorState(state.latest) })); - } - }, - refreshLatestArticles: async () => { - if (get().latest.refreshing) { - return; - } - - set((state) => ({ - ...state, - latest: setRefreshingState(state.latest), - })); - - try { - const response = await getArticlesList( - ArticleFeedApiStates.Fresh, - 1, - 10, - ); - - const articles: ApiArticleFeedItem[] = await response.data; - set((state) => ({ - ...state, - latest: { ...state.latest, ...BASE_STATE, articles }, - })); - ToastAndroid.showWithGravity( - HELP_TEXT.FEED_REFRESHED, - ToastAndroid.SHORT, - ToastAndroid.TOP, - ); - } catch (e) { - console.log("err", e); - set((state) => ({ ...state, latest: setErrorState(state.latest) })); - } - }, - }, - featured: { - ...BASE_STATE, - fetchFeaturedArticles: async (page = 1) => { - const { loading, articles: oldArticles } = get().featured; - if (loading) { - return; - } - - set((state) => ({ - ...state, - featured: setFetchingState(state.featured), - })); - - try { - const response = await getArticlesList( - undefined, - page, - DEFAULT_PAGE_SIZE, - ); - const responseArticles: ApiArticleFeedItem[] = await response.data; - let articles = responseArticles; - if (page > 1) { - articles = perfArrayConcat(oldArticles, responseArticles); - } - - set((state) => ({ - ...state, - featured: { - ...state.featured, - ...BASE_STATE, - page, - articles: articles, - }, - })); - } catch (e) { - set((state) => ({ - ...state, - featured: setErrorState(state.featured), - })); - } - }, - refreshFeaturedArticles: async () => { - if (get().featured.refreshing) { - return; - } - - set((state) => ({ - ...state, - featured: setRefreshingState(state.featured), - })); - - try { - const response = await getArticlesList(undefined, 1, 10); - const articles: ApiArticleFeedItem[] = await response.data; - set((state) => ({ - ...state, - featured: { ...state.featured, ...BASE_STATE, articles }, - })); - ToastAndroid.showWithGravity( - HELP_TEXT.FEED_REFRESHED, - ToastAndroid.SHORT, - ToastAndroid.TOP, - ); - } catch (e) { - set((state) => ({ - ...state, - featured: setErrorState(state.featured), - })); - } - }, - }, - search: { - ...BASE_STATE, - searchArticles: async (q: string, page = 1) => { - const { loading, articles: oldArticles } = get().search; - if (loading) { - return; - } - - set((state) => ({ - ...state, - search: setFetchingState(state.search), - })); - - try { - const response = await searchArticles(q, page, DEFAULT_PAGE_SIZE); - - const responseArticles: ApiArticleFeedItem[] = await response.data; - - let articles = responseArticles; - if (page > 1) { - articles = perfArrayConcat(oldArticles, responseArticles); - } - - set((state) => ({ - ...state, - search: { - ...state.search, - ...BASE_STATE, - page, - articles, - }, - })); - } catch (e) { - set((state) => ({ - ...state, - search: setErrorState(state.search), - })); - } - }, - refreshSearch: async (q: string) => { - if (get().search.refreshing) { - return; - } - - set((state) => ({ - ...state, - search: setRefreshingState(state.search), - })); - - try { - const response = await searchArticles(q, 1, 10); - const articles: ApiArticleFeedItem[] = await response.data; - set((state) => ({ - ...state, - search: { ...state.search, ...BASE_STATE, articles }, - })); - ToastAndroid.showWithGravity( - HELP_TEXT.FEED_REFRESHED, - ToastAndroid.SHORT, - ToastAndroid.TOP, - ); - } catch (e) { - set((state) => ({ - ...state, - search: setErrorState(state.search), - })); - } - }, - - reset: async () => { - set((state) => ({ - ...state, - search: { - ...state.search, - loading: false, - error: false, - articles: [], - }, - })); - }, - }, - }), - shallow, -); - -export default useArticleFeedStore; diff --git a/src/store/helpers.ts b/src/store/helpers.ts deleted file mode 100644 index 917da27b..00000000 --- a/src/store/helpers.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface CommonState { - loading: false; - error: false; - refreshing: false; -} - -export const setFetchingState = <T extends CommonState>(state: T): T => { - return { - ...state, - loading: true, - error: false, - refreshing: false, - }; -}; - -export const setErrorState = <T extends CommonState>(state: T): T => { - return { - ...state, - loading: false, - error: true, - refreshing: false, - }; -}; - -export const setRefreshingState = <T extends CommonState>(state: T): T => { - return { - ...state, - loading: false, - error: false, - refreshing: true, - }; -}; diff --git a/src/store/videos/feed.ts b/src/store/videos/feed.ts deleted file mode 100644 index f27753d3..00000000 --- a/src/store/videos/feed.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ToastAndroid } from "react-native"; -import { createWithEqualityFn } from "zustand/traditional"; -import { shallow } from "zustand/vanilla/shallow"; -import { getVideos } from "../../api"; -import type { ApiVideoListItem } from "../../api/types"; -import { perfArrayConcat } from "../../utils/array"; -import { DEFAULT_PAGE_SIZE, HELP_TEXT } from "../../utils/const"; -import { - type CommonState, - setErrorState, - setFetchingState, - setRefreshingState, -} from "../helpers"; - -export interface VideoFeedState extends CommonState { - videos: ApiVideoListItem[]; - page: number; - fetchVideos: (page?: number) => void; - refreshVideos: () => void; -} - -const BASE_STATE: Omit<VideoFeedState, "fetchVideos" | "refreshVideos"> = { - videos: [], - loading: false, - error: false, - refreshing: false, - page: 1, -}; - -const useVideoFeedStore = createWithEqualityFn<VideoFeedState>()( - (set, get) => ({ - ...BASE_STATE, - videos: [], - loading: false, - error: false, - refreshing: false, - page: 1, - fetchVideos: async (page = 1) => { - const { loading, videos } = get(); - if (loading) { - return; - } - - set((state) => ({ ...state, ...setFetchingState(state) })); - - try { - const response = await getVideos(page, DEFAULT_PAGE_SIZE); - const responseVideos: ApiVideoListItem[] = await response.data; - set((state) => ({ - ...state, - ...BASE_STATE, - page, - videos: perfArrayConcat(videos, responseVideos), - })); - } catch (e) { - set((state) => ({ ...state, ...setErrorState(state) })); - } - }, - refreshVideos: async () => { - if (get().refreshing) { - return; - } - - set((state) => ({ ...state, ...setRefreshingState(state) })); - - try { - const response = await getVideos(1, 10); - const videos: ApiVideoListItem[] = await response.data; - set((state) => ({ - ...state, - ...BASE_STATE, - videos, - })); - ToastAndroid.showWithGravity( - HELP_TEXT.FEED_REFRESHED, - ToastAndroid.SHORT, - ToastAndroid.TOP, - ); - } catch (e) { - set((state) => ({ ...state, ...setErrorState(state) })); - } - }, - }), - shallow, -); - -export default useVideoFeedStore;