diff --git a/src/lib/api/hooks/useCameraEvents.ts b/src/lib/api/hooks/useCameraEvents.ts index d8737ec..c872bfa 100644 --- a/src/lib/api/hooks/useCameraEvents.ts +++ b/src/lib/api/hooks/useCameraEvents.ts @@ -1,6 +1,7 @@ -import {useQuery, UseQueryOptions} from 'react-query'; +import {useInfiniteQuery, UseInfiniteQueryOptions} from 'react-query'; import {API_BASE} from '@env'; +import {DEFAULT_EVENTS_TO_LOAD} from '@utils'; import { CameraEventParams, @@ -29,11 +30,19 @@ const buildEventUrl = (eventId: string) => { }; const fetchEvents = async ( + beforeEpoch?: number, queryParams?: CameraEventParams, snapShotQueryParams?: SnapshotQueryParams, ) => { let params: URLSearchParams | undefined; + const limit = parseInt( + queryParams?.limit || DEFAULT_EVENTS_TO_LOAD.toString(), + 10, + ); if (queryParams) { + if (typeof queryParams.before === 'undefined' && beforeEpoch) { + queryParams.before = beforeEpoch?.toString(); + } params = new URLSearchParams(queryParams as Record); } @@ -57,7 +66,17 @@ const fetchEvents = async ( returnData.push(enrichedEvent); } } - return Promise.resolve(returnData); + let pageParam: number | undefined; + if (returnData?.length === limit) { + /** + * ! There's an edge case here where the number of items is divisible + * by the limit, which means there won't actually be a next page but we + * think that there is. + */ + + pageParam = returnData[limit - 1]?.start_time; + } + return Promise.resolve({events: returnData, pageParam}); } } else { return Promise.reject(new Error('ResNotOK')); @@ -70,13 +89,21 @@ const fetchEvents = async ( export const useCameraEvents = ( params: CameraEventParams, options?: Omit< - UseQueryOptions, + UseInfiniteQueryOptions< + {events: FrigateEvent[]; pageParam?: number}, + unknown, + {events: FrigateEvent[]; pageParam?: number}, + any + >, 'queryFn' >, ) => { - return useQuery({ - queryFn: () => fetchEvents(params), + return useInfiniteQuery({ + queryFn: ({pageParam}) => fetchEvents(pageParam, params), queryKey: ['EVENTS', params], + getNextPageParam: lastPage => { + return lastPage?.pageParam; + }, ...options, }); }; diff --git a/src/screens/EventsScreen/EventsScreen.tsx b/src/screens/EventsScreen/EventsScreen.tsx index c4140f4..e16af7b 100644 --- a/src/screens/EventsScreen/EventsScreen.tsx +++ b/src/screens/EventsScreen/EventsScreen.tsx @@ -14,37 +14,26 @@ import {BaseText, BaseView} from '@components'; import {useAppDataStore} from '@stores'; import {bgBackground} from '@utils'; +import {EventListFooter} from './components/EventListFooter'; import {SectionDateHeader} from './components/SectionDateHeader'; -const FooterComponent = ({length}: {length?: number}) => { - return ( - //? I don't know why but we get some layout shift and that requires adding this height value here - - {!!length && ( - - Showing {length} event{length > 1 && 's'}. - - )} - - ); -}; - export const EventsScreen = () => { const currentCamera = useAppDataStore(state => state.currentCamera); - const { - data: events, - isLoading, - error, - } = useCameraEvents( + const limit = useAppDataStore(state => state.eventsToLoad); + const {data, isLoading, error, fetchNextPage, hasNextPage} = useCameraEvents( { cameras: currentCamera || '', - limit: '10', + limit: limit.toString(), }, {enabled: !!currentCamera}, ); const [collapsedSections, setCollapsedSections] = React.useState(new Set()); + const events = data?.pages.reduce((acc, curr) => { + return acc.concat(curr.events).flat(1); + }, []); + //? PERF: I could see this getting real expensive with more events. Consider moving into a RQ Select function? const groupedEvents = React.useMemo( () => @@ -105,7 +94,12 @@ export const EventsScreen = () => { className={clsx(bgBackground)} extraData={collapsedSections} keyExtractor={(item, index) => item.id + index} - ListFooterComponent={} + ListFooterComponent={ + + } showsVerticalScrollIndicator={false} sections={groupedEvents} renderSectionHeader={props => ( diff --git a/src/screens/EventsScreen/components/EventListFooter.tsx b/src/screens/EventsScreen/components/EventListFooter.tsx new file mode 100644 index 0000000..ec6c8b7 --- /dev/null +++ b/src/screens/EventsScreen/components/EventListFooter.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import {TouchableOpacity} from 'react-native'; + +import {BaseText, BaseView, Label} from '@components'; + +export const EventListFooter = ({ + length, + fetchNextPage, +}: { + length?: number; + fetchNextPage?: () => void; +}) => { + return ( + + {!!length && ( + + Showing {length} event{length > 1 && 's'}. + + )} + {!!fetchNextPage && ( + + + + )} + + ); +}; diff --git a/src/screens/SettingsScreen/SettingsScreen.tsx b/src/screens/SettingsScreen/SettingsScreen.tsx index 7fc27d0..342a66a 100644 --- a/src/screens/SettingsScreen/SettingsScreen.tsx +++ b/src/screens/SettingsScreen/SettingsScreen.tsx @@ -1,15 +1,20 @@ +import React from 'react'; import { Switch, TextInput, TextInputProps, TextProps, + TouchableOpacity, View, ViewProps, } from 'react-native'; +import clsx from 'clsx'; + import {useConfig} from '@api'; import {BaseText, BaseView} from '@components'; import {API_BASE} from '@env'; +import {useAppDataStore} from '@stores'; import {showIPOnly} from '@utils'; const SettingRow = (props: ViewProps) => { @@ -34,8 +39,40 @@ const SettingsTextInput = (props: TextInputProps) => { }; export const SettingsScreen = () => { + // TODO: use something like react-hook-form here + + //? Current setting const {data: config} = useConfig(); const birdseyeEnabled = config?.birdseye?.enabled; + const currentEventsToLoad = useAppDataStore(state => state.eventsToLoad); + const updateEventsToLoad = useAppDataStore(state => state.setEventsToLoad); + + //? Screen state + const [canSave, setCanSave] = React.useState(false); + const [eventsToLoad, setEventsToLoad] = React.useState(currentEventsToLoad); + + React.useEffect(() => { + const eventsLoadHasChanged = eventsToLoad !== currentEventsToLoad; + if (eventsLoadHasChanged) { + setCanSave(true); + } else { + setCanSave(false); + } + }, [currentEventsToLoad, eventsToLoad]); + + const onEventsChange = (val: string) => { + const parsed = parseInt(val, 10); + if (!isNaN(parsed)) { + setEventsToLoad(parsed); + } else { + //? Handle this a bit more gracefully + setEventsToLoad(0); + } + }; + + const onSavePress = () => { + updateEventsToLoad(eventsToLoad); + }; return ( @@ -47,6 +84,26 @@ export const SettingsScreen = () => { Birdseye Enabled + + Events to load + + + + + Save Changes + + ); }; diff --git a/src/stores/appDataStore.ts b/src/stores/appDataStore.ts index d75d322..11fe76d 100644 --- a/src/stores/appDataStore.ts +++ b/src/stores/appDataStore.ts @@ -1,6 +1,8 @@ import {MMKV} from 'react-native-mmkv'; import {create} from 'zustand'; -import {persist, StateStorage} from 'zustand/middleware'; +import {createJSONStorage, persist, StateStorage} from 'zustand/middleware'; + +import {DEFAULT_EVENTS_TO_LOAD} from '@utils'; const appDataStore = new MMKV({ id: 'bird-watcher', @@ -25,8 +27,16 @@ interface AppDataStore { setHasOpenedAppBefore: (b?: boolean) => void; currentCamera?: string; setCurrentCamera: (s: string) => void; + eventsToLoad: number; + setEventsToLoad: (n?: number) => void; } +const defaultDataStore = { + currentCamera: undefined, + hasOpenedAppBefore: false, + eventsToLoad: DEFAULT_EVENTS_TO_LOAD, +}; + export const useAppDataStore = create< AppDataStore, [['zustand/persist', Partial]] @@ -35,13 +45,16 @@ export const useAppDataStore = create< set => ({ currentCamera: undefined, hasOpenedAppBefore: false, + eventsToLoad: DEFAULT_EVENTS_TO_LOAD, + setEventsToLoad: (n?: number) => + set({eventsToLoad: n || defaultDataStore.eventsToLoad}), setHasOpenedAppBefore: (b?: boolean) => set({hasOpenedAppBefore: typeof b === 'undefined' ? true : b}), setCurrentCamera: (currentCamera: string) => set({currentCamera}), }), { name: 'app-storage', - getStorage: () => zustandStorage, + storage: createJSONStorage(() => zustandStorage), // partialize: state => // Object.fromEntries( // Object.entries(state).filter( diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..7eb1bce --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_EVENTS_TO_LOAD = 25; diff --git a/src/utils/index.ts b/src/utils/index.ts index bcbc6ab..1be5558 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './colors'; export * from './urls'; export * from './text'; +export * from './constants';