goon/mobile/src/screens/StudioScenesScreen.tsx
goon-foss a136159e64 fix(mobile): stop full scene-list refetch on back-navigation (perf)
Returning to the Scenes list from a scene caused a full reload + phone load spike (report 5df48551). Cause: invalidateQueries(['scenes']) in SceneDetail/Player/Performer/Studio handlers — including the silent auto-enrich-thumbnail that fires on opening any thumbnail-less scene — forces react-query to refetch EVERY loaded page of the infinite list. Added refetchType:'none' to all ['scenes'] invalidations: marks stale without refetching the active list, which refreshes on pull-to-refresh / filter change instead. Scene detail (['scene', id]) still updates immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:14:07 +02:00

176 lines
6.4 KiB
TypeScript

// Sceny dla wybranego studia (filter studio_slugs=<slug>). Mirror PerformerScenesScreen.
// Studios używają slug filter (nie id) — backend ScenesListParams ma `studio_slugs`,
// nie `studio_ids`. Dlatego nav param dla StudioScenes wymaga slug — przekazujemy go
// z FavoritesScreen (mamy w FavoriteStudioOut.slug).
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut } from '../types';
export function StudioScenesScreen() {
const client = useClient();
const { gridColumns } = usePreferences();
const queryClient = useQueryClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'StudioScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'StudioScenes'>>();
// `id` w nav params dla StudioScenes to studio.slug (nie UUID), żeby pasowało do
// backend ScenesListParams.studio_slugs. Trzymamy tę nazwę dla spójności z
// PerformerScenes — ale w handlerach favorite/blacklist używamy studioId (UUID)
// który dodajemy jako kolejny param.
const { id: slug, name, seenSince, studioId } = route.params;
const favoritesQuery = useQuery({
queryKey: ['favorites-studios'],
queryFn: () => client.listFavoriteStudios(),
staleTime: 30_000,
});
const isFavorite = !!favoritesQuery.data?.items.find((f) => f.studio_id === studioId);
const addMutation = useMutation({
mutationFn: () => client.addFavoriteStudio(studioId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }),
});
const removeMutation = useMutation({
mutationFn: () => client.removeFavoriteStudio(studioId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites-studios'] }),
});
const blacklistMutation = useMutation({
mutationFn: () => client.addBlacklist('studio', studioId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
queryClient.invalidateQueries({ queryKey: ['studio-scenes', slug] });
navigation.goBack();
},
});
const onHide = () => {
Alert.alert(
'Hide studio',
`Hide all scenes from ${name}? You can undo this from Settings → Blacklist.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Hide', style: 'destructive', onPress: () => blacklistMutation.mutate() },
],
);
};
// Mark seen przy wejściu (jeśli już ulubiony) — zerujemy badge nowych scen
React.useEffect(() => {
if (isFavorite) {
client.markFavoriteStudioSeen(studioId).then(() => {
queryClient.invalidateQueries({ queryKey: ['favorites-studios'] });
}).catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFavorite, studioId]);
React.useLayoutEffect(() => {
navigation.setOptions({
title: name,
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
<Pressable onPress={onHide} hitSlop={10}>
<Text style={{ color: theme.mutedDim, fontSize: 18 }}>🚫</Text>
</Pressable>
<Pressable
onPress={() => (isFavorite ? removeMutation.mutate() : addMutation.mutate())}
hitSlop={12}
disabled={addMutation.isPending || removeMutation.isPending}
>
<Text style={{ color: isFavorite ? theme.accent : theme.muted, fontSize: 22 }}>
{isFavorite ? '★' : '☆'}
</Text>
</Pressable>
</View>
),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
queryKey: ['studio-scenes', slug],
queryFn: () =>
client.listScenes({
studio_slugs: [slug],
sort: 'release_date',
per_page: 200,
has_playback: true,
}),
});
const sortedItems = React.useMemo<SceneOut[]>(() => {
const items = data?.items ?? [];
if (!seenSince) return items;
const newOnes: SceneOut[] = [];
const rest: SceneOut[] = [];
for (const s of items) {
if (s.created_at && s.created_at > seenSince) {
newOnes.push(s);
} else {
rest.push(s);
}
}
return [...newOnes, ...rest];
}, [data?.items, seenSince]);
return (
<View style={styles.container}>
{isLoading && <ActivityIndicator color={theme.fg} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
{...sceneGridProps(gridColumns)}
data={sortedItems}
keyExtractor={(s) => s.id}
removeClippedSubviews={false}
renderItem={({ item }) => (
<SceneTile scene={item} seenSince={seenSince} secondLine="performers" />
)}
refreshing={isRefetching}
onRefresh={refetch}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.subtitle}>
{data ? `${data.total}${data.total_capped ? '+' : ''} ${data.total === 1 ? 'scene' : 'scenes'}` : ' '}
</Text>
</View>
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
gridRow: { gap: 10, marginBottom: 14 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 4,
},
subtitle: { color: theme.muted },
muted: { color: theme.muted, textAlign: 'center', marginTop: 24 },
error: { color: theme.bad, padding: 12 },
});