goon/mobile/src/components/BugReportFAB.tsx
goon-foss 21500837ab
Some checks failed
Backend tests / test (push) Has been cancelled
feat(bugreport): auto-attach device + last-playback context
Playback reports ("doesn't start", "audio lags") came in with no way to know the
device or which source/server was used, forcing a follow-up question. Now the bug
report auto-context also carries:
- device = phone model / Android version (Platform.constants, zero-dep)
- play = last-played origin/host, mode, position, and last player error

New in-memory lastPlayback store written by PlayerScreen on source/status change, read
by BugReportFAB when composing a report. Reset on app restart (current session only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:53:30 +03:00

531 lines
17 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,
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<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']);
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<string | null>(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<MyReport[]>([]);
const [unseen, setUnseen] = useState(0);
const refreshMine = useCallback(async (): Promise<number> => {
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<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('');
// Ś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<string, unknown>;
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 ? (
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
<Text style={styles.fabIcon}>?</Text>
{unseen > 0 ? (
<View style={styles.fabDot}>
<Text style={styles.fabDotText}>{unseen > 9 ? '9+' : unseen}</Text>
</View>
) : null}
</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}>
<View style={styles.tabRow}>
<Pressable
style={[styles.tab, mode === 'report' && styles.tabActive]}
onPress={() => setMode('report')}
>
<Text style={[styles.tabText, mode === 'report' && styles.tabTextActive]}>
Report a bug
</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'messages' && styles.tabActive]}
onPress={openMessages}
>
<Text style={[styles.tabText, mode === 'messages' && styles.tabTextActive]}>
Your messages{myReports.length ? ` (${myReports.length})` : ''}
</Text>
{unseen > 0 ? <View style={styles.tabDot} /> : null}
</Pressable>
</View>
{mode === 'report' ? (
<>
{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}>Attach 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 unavailable (capture failed). Sending text only.
</Text>
)}
<TextInput
style={styles.input}
value={message}
onChangeText={setMessage}
placeholder="What's wrong? Scene title / what you're trying to do / what you saw"
placeholderTextColor={theme.mutedDim}
multiline
autoFocus
/>
<View style={styles.btnRow}>
<TouchableOpacity
style={[styles.btn, styles.btnCancel]}
onPress={() => setOpen(false)}
disabled={submitting}
>
<Text style={styles.btnText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.btnSend]}
onPress={submit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color={theme.fg} />
) : (
<Text style={styles.btnText}>Send</Text>
)}
</TouchableOpacity>
</View>
</>
) : (
<ScrollView style={styles.msgList} contentContainerStyle={{ paddingBottom: 8 }}>
{myReports.length === 0 ? (
<Text style={styles.noScreenshot}>
No messages yet. Reports you send show up here with any reply.
</Text>
) : (
myReports.map((r) => (
<View key={r.id} style={styles.msgItem}>
<Text style={styles.msgYou} numberOfLines={4}>
{r.message}
</Text>
{r.response ? (
<View style={styles.replyBox}>
<Text style={styles.replyLabel}>Reply from the team</Text>
<Text style={styles.replyText}>{r.response}</Text>
</View>
) : (
<Text style={styles.noReply}>No reply yet</Text>
)}
</View>
))
)}
<TouchableOpacity
style={[styles.btn, styles.btnCancel, { marginTop: 12 }]}
onPress={() => setOpen(false)}
>
<Text style={styles.btnText}>Close</Text>
</TouchableOpacity>
</ScrollView>
)}
</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',
},
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' },
});