goon/mobile/src/screens/AppLockSettingsScreen.tsx
goon-foss acbc543e2f 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>
2026-06-20 15:53:09 +02:00

380 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import {
AppLockSettings,
biometricAvailable,
clearPin,
DEFAULT_TIMEOUT_SECONDS,
getSettings,
setBioEnabled,
setEnabled,
setPin,
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';
const TIMEOUT_OPTIONS: { label: string; seconds: number }[] = [
{ label: 'Immediately', seconds: 0 },
{ label: '30 sec', seconds: 30 },
{ label: '1 min', seconds: 60 },
{ label: '5 min', seconds: 300 },
{ label: '15 min', seconds: 900 },
];
type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-confirm';
export function AppLockSettingsScreen() {
const [settings, setSettings] = useState<AppLockSettings | null>(null);
const [bioAvailable, setBioAvailable] = useState(false);
const [stage, setStage] = useState<Stage>('menu');
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()]);
setSettings(s);
setBioAvailable(bio);
}
useEffect(() => {
refresh();
}, []);
if (!settings) {
return (
<View style={styles.center}>
<ActivityIndicator color={theme.muted} />
</View>
);
}
if (stage === 'enter-current') {
return (
<PinEntry
title="Enter current PIN"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
}}
onSubmit={async (candidate) => {
const ok = await verifyPin(candidate);
if (!ok) {
setErrorText('Incorrect PIN');
return;
}
setErrorText(null);
setStage('set-new');
}}
/>
);
}
if (stage === 'set-new') {
return (
<PinEntry
title="Enter new PIN (4-8 digits)"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
setNewPin('');
}}
onSubmit={async (candidate) => {
if (candidate.length < 4) {
setErrorText('Min. 4 digits');
return;
}
setNewPin(candidate);
setErrorText(null);
setStage('confirm-new');
}}
/>
);
}
if (stage === 'confirm-new') {
return (
<PinEntry
title="Confirm PIN"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
setNewPin('');
}}
onSubmit={async (candidate) => {
if (candidate !== newPin) {
setErrorText('PINs do not match');
return;
}
await setPin(newPin);
await setEnabled(true);
setNewPin('');
setErrorText(null);
setStage('menu');
await refresh();
Alert.alert('Done', 'PIN saved. App lock enabled.');
}}
/>
);
}
if (stage === 'disable-confirm') {
return (
<PinEntry
title="Enter PIN to disable lock"
error={errorText}
onCancel={() => {
setStage('menu');
setErrorText(null);
}}
onSubmit={async (candidate) => {
const ok = await verifyPin(candidate);
if (!ok) {
setErrorText('Incorrect PIN');
return;
}
await setEnabled(false);
await setBioEnabled(false);
await clearPin();
setErrorText(null);
setStage('menu');
await refresh();
}}
/>
);
}
return (
<ScrollView style={styles.root} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>App lock</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Enabled</Text>
<Text style={styles.hint}>
{settings.enabled ? 'Requires PIN at startup and after a break' : 'Disabled'}
</Text>
</View>
<Switch
value={settings.enabled}
onValueChange={(v) => {
if (v) {
setStage('set-new');
} else if (settings.hasPin) {
setStage('disable-confirm');
} else {
setEnabled(false).then(refresh);
}
}}
trackColor={{ true: theme.accent, false: theme.border }}
thumbColor={theme.fg}
/>
</View>
{settings.enabled && settings.hasPin ? (
<Pressable
style={styles.actionBtn}
onPress={() => {
setStage('enter-current');
setErrorText(null);
}}
>
<Text style={styles.actionText}>Change PIN</Text>
</Pressable>
) : null}
</View>
{settings.enabled ? (
<>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Biometrics</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Fingerprint / Face Unlock</Text>
<Text style={styles.hint}>
{bioAvailable
? 'Faster unlocking, PIN as fallback'
: 'No biometrics on this device'}
</Text>
</View>
<Switch
value={settings.bioEnabled && bioAvailable}
disabled={!bioAvailable}
onValueChange={async (v) => {
await setBioEnabled(v);
await refresh();
}}
trackColor={{ true: theme.accent, false: theme.border }}
thumbColor={theme.fg}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Time until lock</Text>
<Text style={styles.hint}>How long to wait after leaving the app before locking</Text>
<View style={styles.chipRow}>
{TIMEOUT_OPTIONS.map((opt) => {
const active = settings.timeoutSeconds === opt.seconds;
return (
<Pressable
key={opt.seconds}
style={[styles.chip, active && styles.chipActive]}
onPress={async () => {
await setTimeoutSeconds(opt.seconds);
await refresh();
}}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</>
) : null}
<View style={styles.section}>
<Text style={styles.hint}>
The app is also hidden in the recent apps list and blocks screenshots.
</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>
<View style={styles.chipRow}>
{[1, 2, 3].map((n) => {
const active = gridColumns === n;
return (
<Pressable
key={n}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setGridColumns(n)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{n === 1 ? '1 column' : `${n} columns`}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Version</Text>
<Text style={styles.hint}>Current JS bundle (OTA-updated)</Text>
</View>
<Text style={styles.versionValue}>{APP_VERSION}</Text>
</View>
<Pressable
style={styles.row}
onPress={() => Linking.openURL(REPO_URL).catch(() => {})}
>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Source code</Text>
<Text style={styles.hint}>Open source (MIT) audit it or self-host</Text>
</View>
<Text style={styles.linkValue}>{REPO_LABEL} </Text>
</Pressable>
</View>
</ScrollView>
);
}
// Publiczne repo OSS (zgłoszenie usera 4c5066b8: "no source code repo linked").
// Sygnał zaufania dla sideloadowanej apki 18+: audyt kodu / self-host / kontrybucja.
const REPO_URL = 'https://github.com/goon-foss/goon';
const REPO_LABEL = 'goon-foss/goon';
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
center: { flex: 1, backgroundColor: theme.bg, alignItems: 'center', justifyContent: 'center' },
section: {
backgroundColor: theme.card,
marginHorizontal: 14,
marginTop: 14,
padding: 16,
borderRadius: 14,
borderWidth: 1,
borderColor: theme.border,
},
sectionTitle: {
color: theme.fg,
fontSize: 13,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 10,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
label: { color: theme.fg, fontSize: 15, fontWeight: '600' },
hint: { color: theme.muted, fontSize: 12, marginTop: 2 },
actionBtn: {
marginTop: 12,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
alignItems: 'center',
},
actionText: { color: theme.accent, fontSize: 14, fontWeight: '600' },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
chipActive: { backgroundColor: theme.accent, borderColor: theme.accent },
versionValue: { color: theme.fg, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] },
linkValue: { color: theme.accent, fontSize: 15, fontWeight: '700' },
chipText: { color: theme.muted, fontSize: 13 },
chipTextActive: { color: '#fff', fontWeight: '700' },
});