mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-13 01:10:22 +01:00
On the new architecture with Reanimated 4, BottomSheetModal.present() called from a useEffect after a state update silently no-ops: the press registered, open flipped to true, the effect called present() on a valid ref - and nothing mounted (no onChange, nothing in the native tree). Sheets that present() directly inside their press handler (downloads, account picker) kept working, which is what pinned it down. FilterSheet now takes a modalRef and the opener presents imperatively from the gesture handler. The [open] effect only handles closing, and never dismisses a modal that was never presented. The sheet also opens immediately with a loader while options load, instead of the old data-loaded press gate that left the button silently dead. This restores genre/year/tag/sort filters in libraries and collections, and the same pattern is applied to the bitrate/media-source/track sheets that share FilterSheet.
283 lines
8.5 KiB
TypeScript
283 lines
8.5 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import {
|
|
BottomSheetBackdrop,
|
|
type BottomSheetBackdropProps,
|
|
BottomSheetModal,
|
|
BottomSheetScrollView,
|
|
} from "@gorhom/bottom-sheet";
|
|
import { isEqual } from "lodash";
|
|
import type React from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
View,
|
|
type ViewProps,
|
|
} 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";
|
|
|
|
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;
|
|
searchFilter?: (item: T, query: string) => boolean;
|
|
renderItemLabel: (item: T) => React.ReactNode;
|
|
disableSearch?: boolean;
|
|
multiple?: boolean;
|
|
}
|
|
|
|
const LIMIT = 100;
|
|
|
|
/**
|
|
* FilterSheet Component
|
|
*
|
|
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
|
*
|
|
* @template T - The type of items in the list
|
|
*
|
|
* @param {Object} props - The component props
|
|
* @param {boolean} props.open - Whether the bottom sheet is open
|
|
* @param {function} props.setOpen - Function to set the open state
|
|
* @param {T[] | null} [props.data] - The full list of items to filter from
|
|
* @param {T[]} props.values - The currently selected items
|
|
* @param {function} props.set - Function to update the selected items
|
|
* @param {string} props.title - The title of the bottom sheet
|
|
* @param {function} props.searchFilter - Function to filter items based on search query
|
|
* @param {function} props.renderItemLabel - Function to render the label for each item
|
|
* @param {boolean} [props.disableSearch=false] - Whether to disable the search input
|
|
*
|
|
* @returns {React.ReactElement} The FilterSheet component
|
|
*
|
|
* Features:
|
|
* - Displays a list of items in a bottom sheet
|
|
* - Allows searching and filtering of items
|
|
* - Supports single selection of items
|
|
* - Loads items in batches for performance optimization
|
|
* - Customizable item rendering
|
|
*/
|
|
|
|
export const FilterSheet = <T,>({
|
|
values,
|
|
data: _data,
|
|
loading = false,
|
|
open,
|
|
set,
|
|
setOpen,
|
|
modalRef,
|
|
title,
|
|
searchFilter,
|
|
renderItemLabel,
|
|
disableSearch = false,
|
|
multiple = false,
|
|
}: Props<T>) => {
|
|
const bottomSheetModalRef = modalRef;
|
|
const snapPoints = useMemo(() => ["85%"], []);
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
|
|
const [data, setData] = useState<T[]>([]);
|
|
const [offset, setOffset] = useState<number>(0);
|
|
|
|
const [search, setSearch] = useState<string>("");
|
|
|
|
const [showSearch, setShowSearch] = useState<boolean>(false);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!search) return _data;
|
|
const results = [];
|
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
|
if (_data && searchFilter?.(_data[i], search)) {
|
|
results.push(_data[i]);
|
|
}
|
|
}
|
|
return results.slice(0, 100);
|
|
}, [search, _data, searchFilter]);
|
|
|
|
useEffect(() => {
|
|
if (!data || data.length === 0 || disableSearch) return;
|
|
if (data.length > 15) {
|
|
setShowSearch(true);
|
|
}
|
|
}, [data]);
|
|
|
|
// Loads data in batches of LIMIT size, starting from offset,
|
|
// to implement efficient "load more" functionality
|
|
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];
|
|
// Check if this item already exists in our data array
|
|
// some dups happened with re-renders during testing
|
|
const exists = newData.some((existingItem) =>
|
|
isEqual(existingItem, item),
|
|
);
|
|
if (!exists) {
|
|
newData.push(item);
|
|
}
|
|
}
|
|
|
|
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 && wasPresentedRef.current) {
|
|
bottomSheetModalRef.current?.dismiss();
|
|
}
|
|
}, [open]);
|
|
|
|
const handleSheetChanges = useCallback((index: number) => {
|
|
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;
|
|
return data;
|
|
}, [search, filteredData, data]);
|
|
|
|
const renderBackdrop = useCallback(
|
|
(props: BottomSheetBackdropProps) => (
|
|
<BottomSheetBackdrop
|
|
{...props}
|
|
disappearsOnIndex={-1}
|
|
appearsOnIndex={0}
|
|
/>
|
|
),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<BottomSheetModal
|
|
ref={bottomSheetModalRef}
|
|
index={0}
|
|
snapPoints={snapPoints}
|
|
onChange={handleSheetChanges}
|
|
backdropComponent={renderBackdrop}
|
|
handleIndicatorStyle={{
|
|
backgroundColor: "white",
|
|
}}
|
|
backgroundStyle={{
|
|
backgroundColor: "#171717",
|
|
}}
|
|
style={{}}
|
|
>
|
|
<BottomSheetScrollView
|
|
style={{
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<View
|
|
className='mt-2 mb-8'
|
|
style={{
|
|
paddingLeft: Math.max(16, insets.left),
|
|
paddingRight: Math.max(16, insets.right),
|
|
}}
|
|
>
|
|
<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={(text) => {
|
|
setSearch(text);
|
|
}}
|
|
returnKeyType='done'
|
|
/>
|
|
)}
|
|
<View
|
|
style={{
|
|
borderRadius: 20,
|
|
overflow: "hidden",
|
|
}}
|
|
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>
|
|
))}
|
|
</View>
|
|
{data.length < (_data?.length || 0) && (
|
|
<Button
|
|
onPress={() => {
|
|
setOffset(offset + LIMIT);
|
|
}}
|
|
>
|
|
Load more
|
|
</Button>
|
|
)}
|
|
</View>
|
|
</BottomSheetScrollView>
|
|
</BottomSheetModal>
|
|
);
|
|
};
|