/** * 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, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import { captureScreen } from 'react-native-view-shot'; import { GoonClient } from '../api'; import { theme } from '../theme'; import { formatLastPlayback } from '../lib/lastPlayback'; interface Props { client: GoonClient | null; appVersion: string; navRef: NavigationContainerRef; } // 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']); type MyReport = { id: string; created_at: string; screen_name: string | null; message: string; response: string | null; responded_at: string | null; response_seen: boolean; }; export function BugReportFAB({ client, appVersion, navRef }: Props) { const [open, setOpen] = useState(false); const [mode, setMode] = useState<'report' | 'messages'>('report'); const [screenshot, setScreenshot] = useState(null); const [includeScreenshot, setIncludeScreenshot] = useState(true); const [message, setMessage] = useState(''); const [submitting, setSubmitting] = useState(false); // "Your messages": własne zgłoszenia + odpowiedzi admina. `unseen` → kropka na FAB. const [myReports, setMyReports] = useState([]); const [unseen, setUnseen] = useState(0); const refreshMine = useCallback(async (): Promise => { if (!client) return 0; try { const r = await client.listMyBugReports(); setMyReports(r.items); setUnseen(r.unseen); return r.unseen; } catch { // offline / brak — kropki po prostu nie pokazujemy return 0; } }, [client]); // Poll na starcie + co 90s, żeby kropka pojawiła się po odpowiedzi admina. React.useEffect(() => { refreshMine(); const t = setInterval(refreshMine, 90_000); return () => clearInterval(t); }, [refreshMine]); // Wejście w 'messages' = przeczytane → gasimy kropkę (lokalnie + na backendzie). const openMessages = useCallback(async () => { setMode('messages'); if (unseen > 0 && client) { setUnseen(0); try { await client.markBugRepliesSeen(); } catch { // brak sieci — kropka wróci przy następnym refreshMine } } }, [unseen, client]); // Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen. const [currentRoute, setCurrentRoute] = useState(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(''); // Świeży stan wiadomości; jeśli jest nieprzeczytana odpowiedź, otwórz od razu // na 'messages' (user kliknął FAB z kropką, chce ją zobaczyć). const n = await refreshMine(); if (n > 0) { await openMessages(); } else { setMode('report'); } setOpen(true); }, [refreshMine, openMessages]); const submit = useCallback(async () => { if (!client) { Alert.alert('Bug report', 'No connection to the backend.'); return; } if (!message.trim()) { Alert.alert('Bug report', 'Enter a short description.'); return; } setSubmitting(true); let routeName: string | null = null; let rawSceneId: string | undefined; let extraContext: string[] = []; try { const route = navRef.isReady() ? navRef.getCurrentRoute() : null; routeName = route?.name ?? null; const params = (route?.params ?? {}) as Record; rawSceneId = (params['sceneId'] as string | undefined) ?? (params['id'] as string | undefined); // Zbieramy non-scene entity IDs (siteId/studioId/performerId/movieId) jako // hint dla admina — backend schema ma tylko `scene_id`, ale ekrany takie // jak SiteScenes/PerformerScenes/StudioScenes raportują bez konkretnej // sceny ("sceny z tej strony nie działają", bug-report bda4383a 2026-05-26). // Appendujemy do message zamiast schema-migracji. const UUID_RE = /^[0-9a-f-]{36}$/; for (const key of ['siteId', 'studioId', 'performerId', 'movieId', 'tagId']) { const val = params[key]; if (typeof val === 'string' && UUID_RE.test(val)) { extraContext.push(`${key}=${val}`); } } // Identyfikatory tekstowe (nie-UUID): SiteScenes przekazuje stronę jako // `origin` (sitetag) + `name` (display), PerformerScenes/TagScenes bywa // po nazwie. Bez tego zgłoszenie "ingest tej strony stoi" nie mówi której // (bug-report 14f3a655 2026-06-14). Cap długości, żeby nie wlec listy. for (const key of ['origin', 'name', 'sitetag', 'tag', 'q']) { const val = params[key]; if (typeof val === 'string' && val.trim() && !UUID_RE.test(val)) { extraContext.push(`${key}=${val.trim().slice(0, 64)}`); } } } catch { // navRef nie ready — zostawiamy puste, backend i tak przyjmie nullable } // Model telefonu + wersja Androida — zawsze przydatne przy playback/UI bugach (żeby // nie dopytywać usera "z czego korzystasz"). Platform.constants na Androidzie ma Model // + Release; iOS fallback na Version. const pc = Platform.constants as unknown as { Model?: string; Release?: string }; const device = Platform.OS === 'android' ? `${pc?.Model || 'android'} / Android ${pc?.Release || Platform.Version}` : `${Platform.OS} ${Platform.Version}`; extraContext.push(`device=${device.slice(0, 48)}`); // Ostatnie odtwarzanie (serwer/host/pozycja/błąd) — kluczowe dla "nie gra"/"audio // się rozjeżdża", bo mówi KTÓRE źródło i gdzie, niezależnie od ekranu zgłoszenia. const play = formatLastPlayback(); if (play) extraContext.push(`play=${play}`); const sceneId = rawSceneId && /^[0-9a-f-]{36}$/.test(rawSceneId) ? rawSceneId : null; const finalMessage = extraContext.length > 0 ? `${message.trim()}\n\n[auto-context: ${extraContext.join(', ')}]` : message.trim(); try { await client.submitBugReport({ message: finalMessage, screen_name: routeName, app_version: appVersion, scene_id: sceneId, screenshot_b64: includeScreenshot ? screenshot : null, }); setOpen(false); setMessage(''); setScreenshot(null); refreshMine(); Alert.alert('Bug report', 'Sent. Thanks!'); } catch (e) { Alert.alert('Bug report', `Failed to send: ${(e as Error).message}`); } finally { setSubmitting(false); } }, [client, message, screenshot, includeScreenshot, appVersion, navRef]); return ( <> {!hidden ? ( ? {unseen > 0 ? ( {unseen > 9 ? '9+' : unseen} ) : null} ) : null} setOpen(false)} > setOpen(false)} /> setMode('report')} > Report a bug Your messages{myReports.length ? ` (${myReports.length})` : ''} {unseen > 0 ? : null} {mode === 'report' ? ( <> {screenshot ? ( Attach screenshot ) : ( Screenshot unavailable (capture failed). Sending text only. )} setOpen(false)} disabled={submitting} > Cancel {submitting ? ( ) : ( Send )} ) : ( {myReports.length === 0 ? ( No messages yet. Reports you send show up here with any reply. ) : ( myReports.map((r) => ( {r.message} {r.response ? ( Reply from the team {r.response} ) : ( No reply yet )} )) )} setOpen(false)} > Close )} ); } 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', }, fabDot: { position: 'absolute', top: -2, right: -2, minWidth: 18, height: 18, paddingHorizontal: 4, borderRadius: 9, backgroundColor: theme.accent, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: theme.bg, }, fabDotText: { color: theme.fg, fontSize: 10, fontWeight: '800' }, tabRow: { flexDirection: 'row', gap: 8, marginBottom: 14 }, tab: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 9, borderRadius: 8, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, }, tabActive: { borderColor: theme.accent, backgroundColor: theme.bg }, tabText: { color: theme.muted, fontWeight: '600', fontSize: 14 }, tabTextActive: { color: theme.fg }, tabDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.accent }, msgList: { maxHeight: 380 }, msgItem: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 10, }, msgYou: { color: theme.fg, fontSize: 14, lineHeight: 19 }, replyBox: { marginTop: 10, borderLeftWidth: 3, borderLeftColor: theme.accent, paddingLeft: 10, paddingVertical: 4, }, replyLabel: { color: theme.accent, fontSize: 11, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 3, }, replyText: { color: theme.fg, fontSize: 14, lineHeight: 19 }, noReply: { color: theme.mutedDim, fontSize: 12, marginTop: 8, fontStyle: 'italic' }, });