Skip to content

Commit

Permalink
feat: add paginated queries for events
Browse files Browse the repository at this point in the history
  • Loading branch information
billyjacoby committed Sep 25, 2023
1 parent 04450c0 commit 27f33e3
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 27 deletions.
37 changes: 32 additions & 5 deletions src/lib/api/hooks/useCameraEvents.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, string>);
}

Expand All @@ -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'));
Expand All @@ -70,13 +89,21 @@ const fetchEvents = async (
export const useCameraEvents = (
params: CameraEventParams,
options?: Omit<
UseQueryOptions<unknown, unknown, FrigateEvent[], any>,
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,
});
};
34 changes: 14 additions & 20 deletions src/screens/EventsScreen/EventsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<BaseView className="">
{!!length && (
<BaseText className="text-center text-mutedForeground dark:text-mutedForeground-dark pt-2">
Showing {length} event{length > 1 && 's'}.
</BaseText>
)}
</BaseView>
);
};

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<FrigateEvent[]>((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(
() =>
Expand Down Expand Up @@ -105,7 +94,12 @@ export const EventsScreen = () => {
className={clsx(bgBackground)}
extraData={collapsedSections}
keyExtractor={(item, index) => item.id + index}
ListFooterComponent={<FooterComponent length={events?.length} />}
ListFooterComponent={
<EventListFooter
length={events?.length}
fetchNextPage={hasNextPage ? fetchNextPage : undefined}
/>
}
showsVerticalScrollIndicator={false}
sections={groupedEvents}
renderSectionHeader={props => (
Expand Down
29 changes: 29 additions & 0 deletions src/screens/EventsScreen/components/EventListFooter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BaseView className="">
{!!length && (
<BaseText className="text-center text-mutedForeground dark:text-mutedForeground-dark pt-2">
Showing {length} event{length > 1 && 's'}.
</BaseText>
)}
{!!fetchNextPage && (
<TouchableOpacity className="items-center mt-2" onPress={fetchNextPage}>
<Label className="px-3 py-2">
<BaseText>Fetch more</BaseText>
</Label>
</TouchableOpacity>
)}
</BaseView>
);
};
57 changes: 57 additions & 0 deletions src/screens/SettingsScreen/SettingsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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 (
<BaseView className="flex-1 px-4 py-2" isScrollview>
Expand All @@ -47,6 +84,26 @@ export const SettingsScreen = () => {
<SettingsLabel>Birdseye Enabled</SettingsLabel>
<Switch value={birdseyeEnabled} disabled />
</SettingRow>
<SettingRow>
<SettingsLabel>Events to load</SettingsLabel>
<SettingsTextInput
value={eventsToLoad.toString()}
onChangeText={onEventsChange}
keyboardType="number-pad"
/>
</SettingRow>
<TouchableOpacity
className="mb-2 mt-auto self-center"
disabled={!canSave}
onPress={onSavePress}>
<BaseView
className={clsx(
'bg-accent dark:bg-accent-dark px-8 py-2 rounded-md',
!canSave && 'opacity-50',
)}>
<BaseText className={clsx('text-lg')}>Save Changes</BaseText>
</BaseView>
</TouchableOpacity>
</BaseView>
);
};
17 changes: 15 additions & 2 deletions src/stores/appDataStore.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<AppDataStore>]]
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEFAULT_EVENTS_TO_LOAD = 25;
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './colors';
export * from './urls';
export * from './text';
export * from './constants';

0 comments on commit 27f33e3

Please sign in to comment.