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 }, +});