fix(library): restore filters on new arch, shared sheet, per-library memory & full reset

- present the filter sheet imperatively from the press handler (Reanimated 4 no-op via useEffect)
- host filters in a single shared sheet to stop stacked sheets
- keep the search input responsive on large option lists (useDeferredValue)
- reset (X) also clears sort & order via a shared useFilterReset hook
- per-library filter memory, reset on app close, scroll grid back to top
- memoize useFilterOptions and drop debug logging
This commit is contained in:
Gauvain
2026-07-15 18:51:50 +02:00
parent 2e79815d78
commit d2db319593
11 changed files with 922 additions and 314 deletions

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -61,6 +62,7 @@ export const BitrateSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const sorted = useMemo(() => {
if (inverted)
@@ -92,7 +94,10 @@ export const BitrateSheet: React.FC<Props> = ({
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
@@ -103,6 +108,7 @@ export const BitrateSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.quality")}
data={sorted}
values={selected ? [selected] : []}

View File

@@ -1,8 +1,9 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
@@ -23,6 +24,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
const isTv = Platform.isTV;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const getDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
@@ -44,7 +46,10 @@ export const MediaSourceSheet: React.FC<Props> = ({
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)}
onPress={() => {
setOpen(true);
sheetModalRef.current?.present();
}}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
@@ -53,6 +58,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
<FilterSheet
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
title={t("item_card.video")}
data={item.MediaSources || []}
values={selected ? [selected] : []}

View File

@@ -1,9 +1,11 @@
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "./FilterSheet";
import { useFilterSheet } from "./FilterSheetProvider";
interface FilterButtonProps<T> extends ViewProps {
id: string;
@@ -33,22 +35,63 @@ export const FilterButton = <T,>({
icon = "filter",
...props
}: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false);
// When a FilterSheetProvider is present (library / collections), all buttons
// share one sheet so two can never stack. Outside a provider (e.g. logs,
// discover), fall back to this button's own standalone sheet.
const shared = useFilterSheet();
const { data: filters } = useQuery<T[]>({
const { data: filters, isLoading } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
// Standalone-mode state (unused in shared mode).
const [open, setOpen] = useState(false);
const sheetModalRef = useRef<BottomSheetModal | null>(null);
const onButtonPress = useCallback(() => {
if (shared) {
shared.openFilter({
key: `${id}:${queryKey}`,
id,
queryKey,
queryFn,
title,
values: values as unknown[],
set: set as (value: unknown[]) => void,
renderItemLabel: renderItemLabel as (item: unknown) => React.ReactNode,
searchFilter: searchFilter as
| ((item: unknown, query: string) => boolean)
| undefined,
disableSearch,
multiple,
});
return;
}
// present() must run from the press handler: from an effect after a state
// update it silently no-ops on the new architecture and the sheet never
// appears.
setOpen(true);
sheetModalRef.current?.present();
}, [
shared,
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch,
multiple,
]);
return (
<>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<TouchableOpacity onPress={onButtonPress}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -85,18 +128,22 @@ export const FilterButton = <T,>({
)}
</View>
</TouchableOpacity>
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
{!shared && (
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
modalRef={sheetModalRef}
loading={isLoading}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
disableSearch={disableSearch}
multiple={multiple}
/>
)}
</>
);
};

View File

@@ -7,7 +7,14 @@ import {
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
@@ -19,11 +26,21 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
interface Props<T> extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
/**
* Modal ref the opener must use to present() the sheet from inside its
* press handler. On the new architecture with Reanimated 4, present()
* called from an effect after a state update silently no-ops — the sheet
* mounts nothing. Presenting straight from the gesture handler works.
*/
modalRef: React.RefObject<BottomSheetModal | null>;
data?: T[] | null;
/** True while the options are loading — shows a loader inside the sheet. */
loading?: boolean;
values: T[];
set: (value: T[]) => void;
title: string;
@@ -66,16 +83,18 @@ const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
loading = false,
open,
set,
setOpen,
modalRef,
title,
searchFilter,
renderItemLabel,
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = modalRef;
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -84,19 +103,24 @@ export const FilterSheet = <T,>({
const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>("");
// Filtering and re-rendering the option list on every keystroke blocks the
// JS thread on large lists (2000+ tags); the controlled input then snaps the
// native text back to a stale value (lost/reappearing letters). Deferring the
// value keeps the keystroke render cheap and runs the list update after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState<boolean>(false);
const filteredData = useMemo(() => {
if (!search) return _data;
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], search)) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [search, _data, searchFilter]);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
@@ -127,21 +151,28 @@ export const FilterSheet = <T,>({
setData(newData);
}, [offset, _data]);
// Opening is imperative (see the modalRef prop); this effect only closes.
// It also never calls dismiss() on a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
if (!open && wasPresentedRef.current) {
bottomSheetModalRef.current?.dismiss();
}
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData;
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [search, filteredData, data]);
}, [deferredSearch, showSearch, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -154,6 +185,54 @@ export const FilterSheet = <T,>({
[],
);
// Memoized so typing in the search input (urgent render with an unchanged
// deferred value) doesn't rebuild up to 100 row elements per keystroke.
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
// Match the deep-equality rule used to render the selected
// state below — option objects are recreated across renders,
// so reference checks would re-add an already selected item.
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!isSelected) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
)),
[renderData, values, multiple, set, setOpen, renderItemLabel],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
@@ -182,9 +261,15 @@ export const FilterSheet = <T,>({
}}
>
<Text className='font-bold text-2xl'>{title}</Text>
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
@@ -203,43 +288,7 @@ export const FilterSheet = <T,>({
}}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
if (multiple) {
if (!values.includes(item)) set(values.concat(item));
else set(values.filter((v) => v !== item));
setTimeout(() => {
setOpen(false);
}, 250);
} else {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}
}}
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='h-1 divide-neutral-700 '
/>
</View>
))}
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button

View File

@@ -0,0 +1,74 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { type FilterConfig, SharedFilterSheet } from "./SharedFilterSheet";
interface FilterSheetContextType {
openFilter: (config: FilterConfig) => void;
}
const FilterSheetContext = createContext<FilterSheetContextType | null>(null);
/**
* Returns the shared-sheet controller, or null when rendered outside a
* FilterSheetProvider — FilterButton then falls back to its own standalone
* sheet (used by screens that don't host a provider, e.g. logs / discover).
*/
export const useFilterSheet = (): FilterSheetContextType | null =>
useContext(FilterSheetContext);
/**
* Hosts the single shared filter sheet for a screen. Every FilterButton under
* it calls openFilter() to show its options in that one sheet — so two sheets
* can never stack regardless of how fast the buttons are tapped. present() runs
* synchronously from the button's press handler (the modal is always mounted).
*/
export const FilterSheetProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
const modalRef = useRef<BottomSheetModal | null>(null);
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<FilterConfig | null>(null);
// First-wins guard. With a single shared sheet there is exactly one source of
// truth (this ref) reset on the one close path — so unlike a per-button guard
// it can't get stuck on remounts or multiple instances. A second tap during
// the first sheet's open animation is ignored; the first tapped filter wins.
const openRef = useRef(false);
const openFilter = useCallback((next: FilterConfig) => {
if (openRef.current) return;
openRef.current = true;
setConfig(next);
setOpen(true);
modalRef.current?.present();
}, []);
// Single close path for every dismissal (select / swipe / backdrop) — frees
// the guard reliably.
const closeSheet = useCallback(() => {
openRef.current = false;
setOpen(false);
}, []);
const value = useMemo(() => ({ openFilter }), [openFilter]);
return (
<FilterSheetContext.Provider value={value}>
{children}
<SharedFilterSheet
modalRef={modalRef}
open={open}
setOpen={closeSheet}
config={config}
/>
</FilterSheetContext.Provider>
);
};

View File

@@ -1,38 +1,24 @@
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import {
filterByAtom,
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useFilterReset } from "@/hooks/useFilterReset";
interface Props extends TouchableOpacityProps {}
interface Props extends TouchableOpacityProps {
libraryId: string;
}
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
export const ResetFiltersButton: React.FC<Props> = ({
libraryId,
...props
}) => {
const { hasActiveFilters, resetAllFilters } = useFilterReset(libraryId);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0 &&
selectedFilters.length === 0
) {
if (!hasActiveFilters) {
return null;
}
return (
<TouchableOpacity
onPress={() => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
setSelectedFilters([]);
}}
onPress={resetAllFilters}
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
{...props}
>

View File

@@ -0,0 +1,286 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { isEqual } from "lodash";
import type React from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Loader } from "../Loader";
/**
* Config for the filter currently shown by the shared sheet. Generics are erased
* at the FilterButton → provider boundary, so item-typed callbacks use `any`.
*/
export interface FilterConfig {
/** Stable identity — changing it remounts the content with fresh state. */
key: string;
id: string;
queryKey: string;
queryFn: (params: any) => Promise<any>;
title: string;
values: any[];
set: (value: any[]) => void;
renderItemLabel: (item: any) => React.ReactNode;
searchFilter?: (item: any, query: string) => boolean;
disableSearch?: boolean;
multiple?: boolean;
}
const LIMIT = 100;
interface SharedFilterSheetProps {
modalRef: React.RefObject<BottomSheetModal | null>;
open: boolean;
setOpen: (open: boolean) => void;
config: FilterConfig | null;
}
/**
* The single shared filter sheet — one BottomSheetModal hosted by
* FilterSheetProvider for a whole screen; FilterButtons only swap its `config`.
* Because only one modal ever exists, rapid taps across buttons can never stack
* two sheets, so no guard/timer is needed. The modal shell stays mounted with a
* stable ref (present() can run synchronously from the tapping button); the
* inner content is keyed by the active filter so its pagination/search reset
* cleanly between filters.
*/
export const SharedFilterSheet: React.FC<SharedFilterSheetProps> = ({
modalRef,
open,
setOpen,
config,
}) => {
const snapPoints = useMemo(() => ["85%"], []);
const insets = useSafeAreaInsets();
// Opening is imperative (the provider calls present()); this effect only
// closes, and never dismisses a modal that was never presented.
const wasPresentedRef = useRef(false);
useEffect(() => {
if (!open && wasPresentedRef.current) {
modalRef.current?.dismiss();
}
}, [open, modalRef]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index >= 0) {
wasPresentedRef.current = true;
} else if (index === -1) {
wasPresentedRef.current = false;
setOpen(false);
}
},
[setOpen],
);
const requestClose = useCallback(() => setOpen(false), [setOpen]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={modalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
>
<BottomSheetScrollView style={{ flex: 1 }}>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{config && (
<SharedFilterSheetContent
key={config.key}
config={config}
onRequestClose={requestClose}
/>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};
interface SharedFilterSheetContentProps {
config: FilterConfig;
onRequestClose: () => void;
}
const SharedFilterSheetContent: React.FC<SharedFilterSheetContentProps> = ({
config,
onRequestClose,
}) => {
const {
id,
queryKey,
queryFn,
title,
values,
set,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
} = config;
const { t } = useTranslation();
// The options query lives here (deduped with the FilterButton's own query via
// the shared React Query key), so the list stays live after the sheet opens.
const { data: _data, isLoading: loading } = useQuery<any[]>({
queryKey: ["filters", title, queryKey, id],
queryFn,
staleTime: 0,
enabled: !!id && !!queryFn && !!queryKey,
});
const [data, setData] = useState<any[]>([]);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState("");
// Filtering on every keystroke blocks the JS thread on large lists; defer the
// value so the keystroke render stays cheap and the list update runs after.
const deferredSearch = useDeferredValue(search);
const [showSearch, setShowSearch] = useState(false);
const filteredData = useMemo(() => {
if (!deferredSearch) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter?.(_data[i], deferredSearch)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [deferredSearch, _data, searchFilter]);
useEffect(() => {
if (!data || data.length === 0 || disableSearch) return;
if (data.length > 15) {
setShowSearch(true);
}
}, [data, disableSearch]);
// Loads data in batches of LIMIT from offset (efficient "load more").
useEffect(() => {
if (!_data || _data.length === 0) return;
const newData = [...data];
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
const item = _data[i];
// Option objects are recreated across renders → dedupe by value.
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
}
setData(newData);
}, [offset, _data]);
const renderData = useMemo(() => {
if (deferredSearch.length > 0 && showSearch) return filteredData;
return data;
}, [deferredSearch, showSearch, filteredData, data]);
const renderedRows = useMemo(
() =>
renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
const isSelected = values.some((value) => isEqual(value, item));
if (multiple) {
if (!isSelected) set(values.concat(item));
else set(values.filter((value) => !isEqual(value, item)));
setTimeout(() => onRequestClose(), 250);
} else if (!isSelected) {
set([item]);
setTimeout(() => onRequestClose(), 250);
}
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
{values.some((i) => isEqual(i, item)) ? (
<Ionicons name='radio-button-on' size={24} color='white' />
) : (
<Ionicons name='radio-button-off' size={24} color='white' />
)}
</TouchableOpacity>
<View
style={{ height: StyleSheet.hairlineWidth }}
className='h-1 divide-neutral-700'
/>
</View>
)),
[renderData, values, multiple, set, renderItemLabel, onRequestClose],
);
return (
<>
<Text className='font-bold text-2xl'>{title}</Text>
{loading ? (
<View className='my-8 flex items-center justify-center'>
<Loader />
</View>
) : (
<Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
)}
{showSearch && (
<Input
placeholder={t("search.search")}
className='my-2 border-neutral-800 border'
value={search}
onChangeText={setSearch}
returnKeyType='done'
/>
)}
<View
style={{ borderRadius: 20, overflow: "hidden" }}
className='mb-4 flex flex-col rounded-xl overflow-hidden'
>
{renderedRows}
</View>
{data.length < (_data?.length || 0) && (
<Button onPress={() => setOffset(offset + LIMIT)}>Load more</Button>
)}
</>
);
};