Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add paginated queries for events #10

Merged
merged 1 commit into from
Sep 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';