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:
parent
b0e15935c6
commit
8b216018a2
4 changed files with 251 additions and 0 deletions
|
|
@ -16,6 +16,13 @@ export type 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',
|
||||
date: 'June 2026',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|||
import React from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
||||
import { BlacklistScreen } from './screens/BlacklistScreen';
|
||||
import { DonateScreen } from './screens/DonateScreen';
|
||||
import { FavoritesScreen } from './screens/FavoritesScreen';
|
||||
import { MovieDetailScreen } from './screens/MovieDetailScreen';
|
||||
|
|
@ -49,6 +50,7 @@ export type RootStackParamList = {
|
|||
Tags: undefined;
|
||||
TagScenes: { slug: string; name: string };
|
||||
AppLockSettings: undefined;
|
||||
Blacklist: undefined;
|
||||
Donate: undefined;
|
||||
Player: {
|
||||
url: string;
|
||||
|
|
@ -258,6 +260,11 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
|
|||
>
|
||||
{() => <AppLockSettingsScreen />}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen
|
||||
name="Blacklist"
|
||||
component={BlacklistScreen}
|
||||
options={{ title: 'Hidden content' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Donate"
|
||||
component={DonateScreen}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,11 @@ import {
|
|||
setTimeoutSeconds,
|
||||
verifyPin,
|
||||
} from '../lib/applock';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { APP_VERSION } from '../lib/appVersion';
|
||||
import { usePreferences } from '../PreferencesContext';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { theme } from '../theme';
|
||||
import { PinEntry } from './PinEntry';
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ export function AppLockSettingsScreen() {
|
|||
const [newPin, setNewPin] = useState('');
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const { gridColumns, setGridColumns } = usePreferences();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
async function refresh() {
|
||||
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
|
||||
|
|
@ -260,6 +264,17 @@ export function AppLockSettingsScreen() {
|
|||
</Text>
|
||||
</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}>
|
||||
<Text style={styles.sectionTitle}>Grid</Text>
|
||||
<Text style={styles.hint}>Thumbnails per row in scene lists</Text>
|
||||
|
|
|
|||
222
mobile/src/screens/BlacklistScreen.tsx
Normal file
222
mobile/src/screens/BlacklistScreen.tsx
Normal 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 },
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue