goon/mobile/src/components/BugReportFAB.tsx
goon-foss ad0284585b Initial commit
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.
2026-05-20 10:10:22 +02:00

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