feat(mobile): Hidden content screen — blacklist tags/performers/studios

User-report 86a9ec72 ('remove all gay scenes from randomly popping up'): there was no UI to hide a tag, nor to view/undo the blacklist — even though the 'Hide performer' alert promised 'undo from Settings -> Blacklist' (a screen that never existed). New BlacklistScreen: search-and-add any tag to hide (e.g. a category), plus manage/unhide all blacklisted tags/performers/studios. Reached via Settings -> Content -> Hidden content. Backend already drops blacklisted-entity scenes from every /scenes (device-scoped); this just exposes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-20 15:53:09 +02:00
parent b0e15935c6
commit 8b216018a2
4 changed files with 251 additions and 0 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-20b',
date: 'June 2026',
items: [
'Hide content you never want to see: Settings → Hidden content lets you hide any tag, performer or studio (and undo it).',
],
},
{ {
id: '2026-06-20', id: '2026-06-20',
date: 'June 2026', date: 'June 2026',

View file

@ -10,6 +10,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react'; import React from 'react';
import { Pressable, Text, View } from 'react-native'; import { Pressable, Text, View } from 'react-native';
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen'; import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
import { BlacklistScreen } from './screens/BlacklistScreen';
import { DonateScreen } from './screens/DonateScreen'; import { DonateScreen } from './screens/DonateScreen';
import { FavoritesScreen } from './screens/FavoritesScreen'; import { FavoritesScreen } from './screens/FavoritesScreen';
import { MovieDetailScreen } from './screens/MovieDetailScreen'; import { MovieDetailScreen } from './screens/MovieDetailScreen';
@ -49,6 +50,7 @@ export type RootStackParamList = {
Tags: undefined; Tags: undefined;
TagScenes: { slug: string; name: string }; TagScenes: { slug: string; name: string };
AppLockSettings: undefined; AppLockSettings: undefined;
Blacklist: undefined;
Donate: undefined; Donate: undefined;
Player: { Player: {
url: string; url: string;
@ -258,6 +260,11 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
> >
{() => <AppLockSettingsScreen />} {() => <AppLockSettingsScreen />}
</Stack.Screen> </Stack.Screen>
<Stack.Screen
name="Blacklist"
component={BlacklistScreen}
options={{ title: 'Hidden content' }}
/>
<Stack.Screen <Stack.Screen
name="Donate" name="Donate"
component={DonateScreen} component={DonateScreen}

View file

@ -22,8 +22,11 @@ import {
setTimeoutSeconds, setTimeoutSeconds,
verifyPin, verifyPin,
} from '../lib/applock'; } from '../lib/applock';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { APP_VERSION } from '../lib/appVersion'; import { APP_VERSION } from '../lib/appVersion';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { PinEntry } from './PinEntry'; import { PinEntry } from './PinEntry';
@ -44,6 +47,7 @@ export function AppLockSettingsScreen() {
const [newPin, setNewPin] = useState(''); const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns } = usePreferences(); const { gridColumns, setGridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
async function refresh() { async function refresh() {
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]); const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
@ -260,6 +264,17 @@ export function AppLockSettingsScreen() {
</Text> </Text>
</View> </View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Content</Text>
<Pressable style={styles.row} onPress={() => navigation.navigate('Blacklist')}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Hidden content</Text>
<Text style={styles.hint}>Hide tags, performers or studios you never want to see</Text>
</View>
<Text style={styles.linkValue}></Text>
</Pressable>
</View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Grid</Text> <Text style={styles.sectionTitle}>Grid</Text>
<Text style={styles.hint}>Thumbnails per row in scene lists</Text> <Text style={styles.hint}>Thumbnails per row in scene lists</Text>

View file

@ -0,0 +1,222 @@
// Zarządzanie blacklistą treści — ukryte tagi / performerzy / studia. Sceny z
// blacklisted entity wypadają z każdego /scenes (backend auto-apply, app/api/blacklist.py).
//
// Powód (user-report 86a9ec72: "remove all gay scenes from randomly popping up"): nie było
// żadnego UI do (a) ukrycia tagu, (b) podejrzenia/cofnięcia blacklisty — mimo że Alert przy
// "Hide performer" obiecywał "undo from Settings → Blacklist". Ten ekran domyka oba.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import { theme } from '../theme';
import type { BlacklistEntry, BlacklistKind, TagCount } from '../types';
export function BlacklistScreen() {
const client = useClient();
const queryClient = useQueryClient();
const [tagQ, setTagQ] = useState('');
const blacklistQuery = useQuery({
queryKey: ['blacklist'],
queryFn: () => client.listBlacklist(),
});
const tagSearch = useQuery({
queryKey: ['blacklist-tag-search', tagQ],
queryFn: () => client.listTags({ q: tagQ || undefined, per_page: 40 }),
enabled: tagQ.trim().length > 0,
});
const afterChange = () => {
queryClient.invalidateQueries({ queryKey: ['blacklist'] });
// Listy scen filtrują po blackliście — odśwież leniwie (bez wymuszania refetcha).
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
};
const addTag = useMutation({
mutationFn: (id: string) => client.addBlacklist('tag', id),
onSuccess: afterChange,
});
const remove = useMutation({
mutationFn: ({ kind, id }: { kind: BlacklistKind; id: string }) =>
client.removeBlacklist(kind, id),
onSuccess: afterChange,
});
const bl = blacklistQuery.data;
const blacklistedTagIds = new Set((bl?.tags ?? []).map((t) => t.id));
const renderRow = (kind: BlacklistKind, e: BlacklistEntry) => (
<View key={`${kind}-${e.id}`} style={styles.row}>
<Text style={styles.rowName} numberOfLines={1}>
{e.name}
</Text>
<Pressable
hitSlop={10}
onPress={() => remove.mutate({ kind, id: e.id })}
style={styles.removeBtn}
>
<Text style={styles.removeText}>Unhide</Text>
</Pressable>
</View>
);
return (
<FlatList
style={styles.root}
data={[1]}
keyExtractor={() => 'x'}
renderItem={() => (
<View style={{ paddingBottom: 40 }}>
<Text style={styles.intro}>
Scenes tagged with or from anything you hide here stop showing up anywhere in
the app. Add a tag below (e.g. a category you never want to see).
</Text>
<Text style={styles.sectionTitle}>Hide a tag</Text>
<TextInput
style={styles.search}
value={tagQ}
onChangeText={setTagQ}
placeholder="search tags to hide…"
placeholderTextColor={theme.muted}
autoCapitalize="none"
autoCorrect={false}
/>
{tagQ.trim().length > 0 ? (
tagSearch.isLoading ? (
<ActivityIndicator color={theme.fg} style={{ margin: 12 }} />
) : (
<View style={styles.chips}>
{(tagSearch.data?.items ?? []).map((tag: TagCount) => {
const already = blacklistedTagIds.has(tag.id);
return (
<Pressable
key={tag.id}
disabled={already || addTag.isPending}
style={[styles.chip, already && styles.chipDisabled]}
onPress={() => addTag.mutate(tag.id)}
>
<Text style={[styles.chipText, already && styles.chipTextDisabled]}>
{already ? '✓ ' : '+ '}
{tag.name}
</Text>
</Pressable>
);
})}
</View>
)
) : null}
{blacklistQuery.isLoading ? (
<ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />
) : (
<>
<Section title="Hidden tags" count={bl?.tags.length ?? 0}>
{(bl?.tags ?? []).map((e) => renderRow('tag', e))}
</Section>
<Section title="Hidden performers" count={bl?.performers.length ?? 0}>
{(bl?.performers ?? []).map((e) => renderRow('performer', e))}
</Section>
<Section title="Hidden studios" count={bl?.studios.length ?? 0}>
{(bl?.studios ?? []).map((e) => renderRow('studio', e))}
</Section>
</>
)}
</View>
)}
/>
);
}
function Section({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{title} {count > 0 ? `(${count})` : ''}
</Text>
{count === 0 ? <Text style={styles.empty}>nothing hidden</Text> : children}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
intro: {
color: theme.muted,
fontSize: 13,
lineHeight: 19,
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 4,
},
section: {
paddingVertical: 12,
borderTopColor: theme.border,
borderTopWidth: 1,
marginTop: 12,
},
sectionTitle: {
color: theme.muted,
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 10,
paddingHorizontal: 16,
},
search: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
color: theme.fg,
padding: 10,
marginHorizontal: 16,
},
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, padding: 16 },
chip: {
backgroundColor: theme.card,
borderColor: theme.accent,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 5,
},
chipDisabled: { borderColor: theme.border, opacity: 0.6 },
chipText: { color: theme.accent, fontSize: 13 },
chipTextDisabled: { color: theme.muted },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 16,
},
rowName: { color: theme.fg, fontSize: 15, flex: 1, marginRight: 12 },
removeBtn: {
borderColor: theme.border,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 6,
},
removeText: { color: theme.accent, fontSize: 13, fontWeight: '600' },
empty: { color: theme.muted, fontSize: 13, paddingHorizontal: 16 },
});