Files
streamyfin/components/filters/FilterButton.tsx
Gauvain 1e7f5a4ef8 fix(library): host filters in a single shared sheet to stop stacked sheets
Each FilterButton owned its own bottom-sheet modal, so tapping two
filter buttons quickly could present two sheets at once. Add a
FilterSheetProvider that hosts one shared sheet per screen; buttons just
swap its content via openFilter(). A first-wins guard in the provider
(one source of truth, reset on the single close path) ignores a second
tap while a sheet is opening. The generic FilterSheet is untouched, so
the bitrate/source/track selectors and provider-less screens (logs,
discover) keep their standalone sheet. Also drop the header deps that
remounted the whole filter bar on every fetch.
2026-06-28 01:24:05 +02:00

150 lines
4.0 KiB
TypeScript

import { FontAwesome, Ionicons } from "@expo/vector-icons";
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
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;
disableSearch?: boolean;
queryKey: string;
values: T[];
title: string;
set: (value: T[]) => void;
queryFn: (params: any) => Promise<any>;
searchFilter?: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
multiple?: boolean;
icon?: "filter" | "sort";
}
export const FilterButton = <T,>({
id,
queryFn,
queryKey,
set,
values, // selected values
title,
renderItemLabel,
searchFilter,
disableSearch = false,
multiple = false,
icon = "filter",
...props
}: FilterButtonProps<T>) => {
// 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, 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={onButtonPress}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
${filters?.length === 0 && "opacity-50"}
`}
{...props}
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
</Text>
{icon === "filter" ? (
<Ionicons
name='filter'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name='sort'
size={14}
color='white'
style={{ opacity: 0.5 }}
/>
)}
</View>
</TouchableOpacity>
{!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}
/>
)}
</>
);
};