Goon — self-hosted aggregator for adult-content scene metadata. Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites. Cross-source deduplication via perceptual hash + Levenshtein distance. FastAPI backend + APScheduler worker + React Native (Expo) mobile client. FOSS, ad-free, donation-funded. See README for details.
321 lines
9 KiB
TypeScript
321 lines
9 KiB
TypeScript
/**
|
|
* Floating "?" button + bug report modal — wisi nad każdą stroną aplikacji.
|
|
*
|
|
* UX:
|
|
* 1. Tap FAB → app robi screenshot CIEnte (przed otwarciem modal'u, więc nie
|
|
* zawiera samego buttonu/modal'u) przez react-native-view-shot.captureScreen.
|
|
* 2. Modal pokazuje preview screenshota + TextInput + opcję "wyślij bez screena"
|
|
* + Send/Cancel.
|
|
* 3. Send → POST /bug-reports z message + screen_name (z React Navigation focused
|
|
* route) + scene_id (jeśli dostarczone w nav params) + base64 screenshot.
|
|
*
|
|
* Screenshot omija FLAG_SECURE bo to in-process render (View.draw → bitmap),
|
|
* nie systemowy MediaProjection.
|
|
*/
|
|
import type { NavigationContainerRef, ParamListBase } from '@react-navigation/native';
|
|
import React, { useCallback, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
KeyboardAvoidingView,
|
|
Modal,
|
|
Platform,
|
|
Pressable,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { captureScreen } from 'react-native-view-shot';
|
|
|
|
import { GoonClient } from '../api';
|
|
import { theme } from '../theme';
|
|
|
|
interface Props {
|
|
client: GoonClient | null;
|
|
appVersion: string;
|
|
navRef: NavigationContainerRef<ParamListBase>;
|
|
}
|
|
|
|
// Screens na których FAB jest ukryty — Player ma fullscreen controls i FAB
|
|
// nakłada się na progress bar / fullscreen button (bug #f53e50b9 screenshot
|
|
// pokazał "?" zasłaniający duration label).
|
|
const FAB_HIDDEN_SCREENS = new Set(['Player']);
|
|
|
|
export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [screenshot, setScreenshot] = useState<string | null>(null);
|
|
const [includeScreenshot, setIncludeScreenshot] = useState(true);
|
|
const [message, setMessage] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
// Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen.
|
|
const [currentRoute, setCurrentRoute] = useState<string | null>(null);
|
|
React.useEffect(() => {
|
|
if (!navRef) return;
|
|
const unsub = navRef.addListener('state', () => {
|
|
try {
|
|
const r = navRef.isReady() ? navRef.getCurrentRoute() : null;
|
|
setCurrentRoute(r?.name ?? null);
|
|
} catch {
|
|
setCurrentRoute(null);
|
|
}
|
|
});
|
|
return unsub;
|
|
}, [navRef]);
|
|
const hidden = currentRoute !== null && FAB_HIDDEN_SCREENS.has(currentRoute);
|
|
|
|
const onPress = useCallback(async () => {
|
|
let captured: string | null = null;
|
|
try {
|
|
const dataUri = await captureScreen({
|
|
format: 'jpg',
|
|
quality: 0.6,
|
|
result: 'data-uri',
|
|
});
|
|
captured = dataUri.split(',')[1] || null;
|
|
} catch (e) {
|
|
captured = null;
|
|
}
|
|
setScreenshot(captured);
|
|
setIncludeScreenshot(captured !== null);
|
|
setMessage('');
|
|
setOpen(true);
|
|
}, []);
|
|
|
|
const submit = useCallback(async () => {
|
|
if (!client) {
|
|
Alert.alert('Bug report', 'Brak połączenia z backendem.');
|
|
return;
|
|
}
|
|
if (!message.trim()) {
|
|
Alert.alert('Bug report', 'Wpisz krótki opis.');
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
let routeName: string | null = null;
|
|
let rawSceneId: string | undefined;
|
|
try {
|
|
const route = navRef.isReady() ? navRef.getCurrentRoute() : null;
|
|
routeName = route?.name ?? null;
|
|
const params = (route?.params ?? {}) as Record<string, unknown>;
|
|
rawSceneId =
|
|
(params['sceneId'] as string | undefined) ??
|
|
(params['id'] as string | undefined);
|
|
} catch {
|
|
// navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable
|
|
}
|
|
const sceneId =
|
|
rawSceneId && /^[0-9a-f-]{36}$/.test(rawSceneId) ? rawSceneId : null;
|
|
try {
|
|
await client.submitBugReport({
|
|
message: message.trim(),
|
|
screen_name: routeName,
|
|
app_version: appVersion,
|
|
scene_id: sceneId,
|
|
screenshot_b64: includeScreenshot ? screenshot : null,
|
|
});
|
|
setOpen(false);
|
|
setMessage('');
|
|
setScreenshot(null);
|
|
Alert.alert('Bug report', 'Wysłano. Dzięki!');
|
|
} catch (e) {
|
|
Alert.alert('Bug report', `Nie udało się wysłać: ${(e as Error).message}`);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [client, message, screenshot, includeScreenshot, appVersion, navRef]);
|
|
|
|
return (
|
|
<>
|
|
{!hidden ? (
|
|
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
|
|
<Text style={styles.fabIcon}>?</Text>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
|
|
<Modal
|
|
visible={open}
|
|
animationType="slide"
|
|
transparent
|
|
onRequestClose={() => setOpen(false)}
|
|
>
|
|
<KeyboardAvoidingView
|
|
style={styles.backdrop}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<Pressable style={styles.backdropTap} onPress={() => setOpen(false)} />
|
|
<View style={styles.sheet}>
|
|
<Text style={styles.title}>Zgłoś buga</Text>
|
|
|
|
{screenshot ? (
|
|
<View style={styles.preview}>
|
|
<Image
|
|
source={{ uri: `data:image/jpeg;base64,${screenshot}` }}
|
|
style={styles.previewImg}
|
|
resizeMode="contain"
|
|
/>
|
|
<View style={styles.toggleRow}>
|
|
<Text style={styles.toggleLabel}>Dołącz screenshot</Text>
|
|
<Switch
|
|
value={includeScreenshot}
|
|
onValueChange={setIncludeScreenshot}
|
|
thumbColor={includeScreenshot ? theme.accent : theme.muted}
|
|
trackColor={{
|
|
true: theme.accentDeep,
|
|
false: theme.border,
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.noScreenshot}>
|
|
Screenshot niedostępny (capture fail). Wyślę sam tekst.
|
|
</Text>
|
|
)}
|
|
|
|
<TextInput
|
|
style={styles.input}
|
|
value={message}
|
|
onChangeText={setMessage}
|
|
placeholder="Co jest nie tak? Tytuł sceny / co próbujesz zrobić / co zobaczyłeś"
|
|
placeholderTextColor={theme.mutedDim}
|
|
multiline
|
|
autoFocus
|
|
/>
|
|
|
|
<View style={styles.btnRow}>
|
|
<TouchableOpacity
|
|
style={[styles.btn, styles.btnCancel]}
|
|
onPress={() => setOpen(false)}
|
|
disabled={submitting}
|
|
>
|
|
<Text style={styles.btnText}>Anuluj</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.btn, styles.btnSend]}
|
|
onPress={submit}
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? (
|
|
<ActivityIndicator color={theme.fg} />
|
|
) : (
|
|
<Text style={styles.btnText}>Wyślij</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
fab: {
|
|
position: 'absolute',
|
|
right: 16,
|
|
bottom: 24,
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
backgroundColor: theme.accentDeep,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
elevation: 6,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 6,
|
|
shadowOffset: { width: 0, height: 3 },
|
|
opacity: 0.85,
|
|
},
|
|
fabIcon: {
|
|
color: theme.fg,
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
},
|
|
backdrop: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
justifyContent: 'flex-end',
|
|
},
|
|
backdropTap: {
|
|
flex: 1,
|
|
},
|
|
sheet: {
|
|
backgroundColor: theme.bgElevated,
|
|
borderTopLeftRadius: 20,
|
|
borderTopRightRadius: 20,
|
|
padding: 20,
|
|
paddingBottom: 28,
|
|
maxHeight: '90%',
|
|
},
|
|
title: {
|
|
color: theme.fg,
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
marginBottom: 12,
|
|
},
|
|
preview: {
|
|
marginBottom: 12,
|
|
},
|
|
previewImg: {
|
|
width: '100%',
|
|
height: 180,
|
|
borderRadius: 8,
|
|
backgroundColor: theme.bg,
|
|
},
|
|
toggleRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginTop: 8,
|
|
},
|
|
toggleLabel: {
|
|
color: theme.muted,
|
|
fontSize: 14,
|
|
},
|
|
noScreenshot: {
|
|
color: theme.warn,
|
|
fontSize: 13,
|
|
marginBottom: 12,
|
|
textAlign: 'center',
|
|
},
|
|
input: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 8,
|
|
color: theme.fg,
|
|
padding: 12,
|
|
minHeight: 100,
|
|
textAlignVertical: 'top',
|
|
marginBottom: 16,
|
|
},
|
|
btnRow: {
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
},
|
|
btn: {
|
|
flex: 1,
|
|
paddingVertical: 12,
|
|
borderRadius: 8,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
btnCancel: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
},
|
|
btnSend: {
|
|
backgroundColor: theme.accent,
|
|
},
|
|
btnText: {
|
|
color: theme.fg,
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
},
|
|
});
|