Some checks failed
Backend tests / test (push) Has been cancelled
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>
531 lines
17 KiB
TypeScript
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' },
|
|
});
|