diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts
index 1761bea..472fcee 100644
--- a/mobile/src/changelog.ts
+++ b/mobile/src/changelog.ts
@@ -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',
diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx
index d27c83e..2fb531f 100644
--- a/mobile/src/navigation.tsx
+++ b/mobile/src/navigation.tsx
@@ -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
>
{() => }
+
(null);
const { gridColumns, setGridColumns } = usePreferences();
+ const navigation = useNavigation>();
async function refresh() {
const [s, bio] = await Promise.all([getSettings(), biometricAvailable()]);
@@ -260,6 +264,17 @@ export function AppLockSettingsScreen() {
+
+ Content
+ navigation.navigate('Blacklist')}>
+
+ Hidden content
+ Hide tags, performers or studios you never want to see
+
+ ›
+
+
+
Grid
Thumbnails per row in scene lists
diff --git a/mobile/src/screens/BlacklistScreen.tsx b/mobile/src/screens/BlacklistScreen.tsx
new file mode 100644
index 0000000..c1086d9
--- /dev/null
+++ b/mobile/src/screens/BlacklistScreen.tsx
@@ -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) => (
+
+
+ {e.name}
+
+ remove.mutate({ kind, id: e.id })}
+ style={styles.removeBtn}
+ >
+ Unhide
+
+
+ );
+
+ return (
+ 'x'}
+ renderItem={() => (
+
+
+ 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).
+
+
+ Hide a tag
+
+ {tagQ.trim().length > 0 ? (
+ tagSearch.isLoading ? (
+
+ ) : (
+
+ {(tagSearch.data?.items ?? []).map((tag: TagCount) => {
+ const already = blacklistedTagIds.has(tag.id);
+ return (
+ addTag.mutate(tag.id)}
+ >
+
+ {already ? '✓ ' : '+ '}
+ {tag.name}
+
+
+ );
+ })}
+
+ )
+ ) : null}
+
+ {blacklistQuery.isLoading ? (
+
+ ) : (
+ <>
+
+ {(bl?.tags ?? []).map((e) => renderRow('tag', e))}
+
+
+ {(bl?.performers ?? []).map((e) => renderRow('performer', e))}
+
+
+ {(bl?.studios ?? []).map((e) => renderRow('studio', e))}
+
+ >
+ )}
+
+ )}
+ />
+ );
+}
+
+function Section({
+ title,
+ count,
+ children,
+}: {
+ title: string;
+ count: number;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title} {count > 0 ? `(${count})` : ''}
+
+ {count === 0 ? nothing hidden : children}
+
+ );
+}
+
+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 },
+});